ooo
This commit is contained in:
parent
e2d2dfcb66
commit
8f80997b0d
256 changed files with 4044 additions and 966 deletions
694
render.js
694
render.js
|
|
@ -1,3 +1,47 @@
|
|||
const displayTerms = {
|
||||
"roaming": "移動中",
|
||||
"grazing": "放牧中",
|
||||
"leaving": "離脱中",
|
||||
"settling": "定住中",
|
||||
"trading": "交易中",
|
||||
"raiding": "襲撃中",
|
||||
"invading": "侵攻中",
|
||||
"confederating": "連合形成中",
|
||||
"frontier_colonization": "辺境開拓",
|
||||
"nonstate_subjugation": "非国家地域の征服",
|
||||
"independent_city_annexation": "独立都市の併合",
|
||||
"border_war": "国境戦争",
|
||||
"frontier": "辺境",
|
||||
"nonstate": "非国家地域",
|
||||
"independentCity": "独立都市",
|
||||
"polity": "国家",
|
||||
"started": "開始",
|
||||
"ended": "終結",
|
||||
"ceased": "停戦",
|
||||
"defending": "防衛",
|
||||
"succeeded": "成功",
|
||||
"failed": "失敗",
|
||||
"peaceful": "平和的併合",
|
||||
"military": "戦争による併合",
|
||||
"expired": "期限切れ",
|
||||
"active": "進行中",
|
||||
"puppetIndependent": "傀儡都市化",
|
||||
"nomadicDynasty": "遊牧王朝の成立",
|
||||
"capturedCity": "都市占領",
|
||||
"lostCity": "都市喪失",
|
||||
"settlementGain": "講和による領土獲得",
|
||||
"settlementLoss": "講和による領土喪失",
|
||||
"settlementPressure": "講和圧力",
|
||||
"dissolved": "解体",
|
||||
"collapsed": "崩壊",
|
||||
"conquest": "征服",
|
||||
"war": "戦争",
|
||||
"event": "発生",
|
||||
"target": "対象",
|
||||
"campaign": "遠征",
|
||||
"non-state": "非国家地域"
|
||||
};
|
||||
function displayTerm(value) { return displayTerms[value] || "状況変化"; }
|
||||
function render() {
|
||||
const start = performance.now();
|
||||
const w = sim.world;
|
||||
|
|
@ -9,7 +53,6 @@ renderImageSize = size;
|
|||
const image = renderImage;
|
||||
const data = image.data;
|
||||
const mode = els.viewMode.value;
|
||||
const cityPolityColor = new Map();
|
||||
const polityColors = new Map();
|
||||
if (mode === "polities") {
|
||||
for (const polity of sim.polities) {
|
||||
|
|
@ -18,16 +61,6 @@ color: polity.color,
|
|||
isGraphHover: graphState.hoverPolityId === polity.id
|
||||
});
|
||||
}
|
||||
for (const city of sim.cities) {
|
||||
if (city.polityId === null) continue;
|
||||
const polity = cityPolity(city);
|
||||
if (!polity) continue;
|
||||
const isGraphHover = graphState.hoverPolityId === polity.id;
|
||||
cityPolityColor.set(city.id, {
|
||||
color: polity.color,
|
||||
isGraphHover
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < w.count; i++) {
|
||||
let color;
|
||||
|
|
@ -55,12 +88,12 @@ const city = sim.getCityById(w.city[i]);
|
|||
if (city) color = mix(color, cityMajorityColor(city), 0.72);
|
||||
}
|
||||
} else if (mode === "pressure") {
|
||||
const pressure = w.populationPressure?.[i] ?? (w.pressure[i] / 12);
|
||||
const pressure = w.populationPressure[i] || 0;
|
||||
const v = clamp(pressure, 0, 1.6) / 1.6;
|
||||
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
|
||||
} else if (mode === "polities") {
|
||||
color = terrainInfo[w.terrain[i]].color;
|
||||
const territory = w.polity?.[i] ?? -1;
|
||||
const territory = w.territoryOwner[i];
|
||||
if (territory >= 0) {
|
||||
const polityColor = polityColors.get(territory);
|
||||
if (polityColor) {
|
||||
|
|
@ -71,13 +104,6 @@ if (sim.isBorderTile?.(i)) color = mix(color, [232, 225, 185], 0.22);
|
|||
if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.30);
|
||||
}
|
||||
}
|
||||
if (w.city[i] >= 0) {
|
||||
const polityColor = cityPolityColor.get(w.city[i]);
|
||||
if (polityColor) {
|
||||
color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5);
|
||||
if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28);
|
||||
}
|
||||
}
|
||||
} else if (mode === "technology") {
|
||||
const farming = w.farmingKnowledge?.[i] || 0;
|
||||
const metallurgy = w.metallurgyKnowledge?.[i] || 0;
|
||||
|
|
@ -106,7 +132,7 @@ drawCampaigns();
|
|||
drawPopulationAndCities(mode);
|
||||
drawGraphPolityHighlight();
|
||||
maybeRenderStateGraph();
|
||||
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
|
||||
if (els.frameCost) els.frameCost.textContent = `${Math.round(performance.now() - start)}ミリ秒`;
|
||||
}
|
||||
function renderDisasters() {
|
||||
if (!sim?.disasters?.length) return;
|
||||
|
|
@ -184,6 +210,7 @@ const w = sim.world;
|
|||
ctx.save();
|
||||
for (const campaign of sim.campaigns) {
|
||||
if (campaign.status !== "active" || campaign.targetTile == null) continue;
|
||||
if (campaign.type !== "border_war") continue;
|
||||
const x = campaign.targetTile % w.size;
|
||||
const y = Math.floor(campaign.targetTile / w.size);
|
||||
const age = Math.max(0, sim.year - campaign.started) / Math.max(1, campaign.expires - campaign.started);
|
||||
|
|
@ -246,25 +273,13 @@ ctx.restore();
|
|||
}
|
||||
function drawPopulationAndCities(mode) {
|
||||
ctx.save();
|
||||
if (mode !== "ethnicity") {
|
||||
for (const city of sim.cities) {
|
||||
const radius = cityRenderRadius(city);
|
||||
const color = cityDisplayColor(city, mode);
|
||||
ctx.fillStyle = rgba(color, 0.72);
|
||||
ctx.globalAlpha = 0.88;
|
||||
fillSquare(ctx, city.x, city.y, radius);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (mode === "technology") {
|
||||
// Tile-level technology is rendered in the base pass.
|
||||
} else if (mode === "ethnicity") {
|
||||
// Ethnicity view is driven by tile-level population/culture fields.
|
||||
} else {
|
||||
if (mode !== "technology" && mode !== "ethnicity") {
|
||||
const w = sim.world;
|
||||
ctx.fillStyle = "rgba(238, 236, 207, 0.16)";
|
||||
for (let tile = 0; tile < w.count; tile++) {
|
||||
const pop = w.population?.[tile] || 0;
|
||||
const populatedTiles = sim.activePopulationTiles || [];
|
||||
for (const tile of populatedTiles) {
|
||||
const pop = (w.population?.[tile] || 0) / (SimConfig.population.populationScale || 1);
|
||||
if (pop < 6) continue;
|
||||
const alpha = clamp(Math.log1p(pop) / Math.log(160), 0.04, 0.18);
|
||||
ctx.fillStyle = `rgba(238, 236, 207, ${alpha})`;
|
||||
|
|
@ -272,15 +287,14 @@ ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
|
|||
}
|
||||
}
|
||||
for (const city of sim.cities) {
|
||||
if (mode === "ethnicity") {
|
||||
const radius = cityRenderRadius(city);
|
||||
const color = cityDisplayColor(city, mode);
|
||||
ctx.globalAlpha = 0.90;
|
||||
ctx.fillStyle = rgba(color, 0.78);
|
||||
fillSquare(ctx, city.x, city.y, radius);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
const color = cityDisplayColor(city, mode);
|
||||
ctx.fillStyle = rgb(color);
|
||||
fillSquare(ctx, city.x, city.y, radius);
|
||||
ctx.strokeStyle = "#111315";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(city.x - radius + 0.5, city.y - radius + 0.5, radius * 2, radius * 2);
|
||||
ctx.fillStyle = rgb(color, 55);
|
||||
ctx.fillRect(city.x, city.y, 1, 1);
|
||||
}
|
||||
|
|
@ -308,7 +322,19 @@ ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 -
|
|||
ctx.restore();
|
||||
}
|
||||
function cityRenderRadius(city) {
|
||||
return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7);
|
||||
const population = Math.max(0, city?.population || 0);
|
||||
if (population >= 10000) return 4;
|
||||
if (population >= 5000) return 3;
|
||||
if (population >= 1000) return 2;
|
||||
return 1;
|
||||
}
|
||||
function cityAtDisplayTile(x, y) {
|
||||
for (let index = sim.cities.length - 1; index >= 0; index--) {
|
||||
const city = sim.cities[index];
|
||||
const radius = cityRenderRadius(city);
|
||||
if (Math.abs(city.x - x) <= radius && Math.abs(city.y - y) <= radius) return city;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function cityMajorityColor(city) {
|
||||
const id = dominantComposition(city.ethnicityComposition);
|
||||
|
|
@ -325,12 +351,6 @@ return [218, 205, 154];
|
|||
}
|
||||
return cityMajorityColor(city);
|
||||
}
|
||||
function techLevel(holder, key) {
|
||||
return holder?.tech?.[key] || 0;
|
||||
}
|
||||
function knowledgeLevel(city, key) {
|
||||
return city?.knowledge?.[key] || 0;
|
||||
}
|
||||
function showTooltip(event) {
|
||||
const rect = els.canvas.getBoundingClientRect();
|
||||
const simRect = els.sim.getBoundingClientRect();
|
||||
|
|
@ -363,80 +383,64 @@ return;
|
|||
}
|
||||
const w = sim.world;
|
||||
const i = w.idx(x, y);
|
||||
const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null;
|
||||
const city = cityAtDisplayTile(x, y);
|
||||
if (w.terrain[i] === Terrain.WATER && w.city[i] < 0) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
const terrain = terrainInfo[w.terrain[i]];
|
||||
const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
|
||||
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
|
||||
const polity = cityPolity(city);
|
||||
const territoryId = w.polity?.[i] ?? -1;
|
||||
const territoryPolity = territoryId >= 0 ? sim.getPolityById(territoryId) : null;
|
||||
const ethnicMix = sim.tileEthnicMix?.get(i);
|
||||
const tileDominantEthnicity = ethnicMix?.size ? dominantComposition(ethnicMix) : w.dominantEthnicity[i];
|
||||
const regionalEthnicity = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? `E${tileDominantEthnicity}` : "-";
|
||||
const regionalLineage = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? sim.ethnicities.get(tileDominantEthnicity) : null;
|
||||
const mismatch = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? sim.climateMismatch(tileDominantEthnicity, i) : 0;
|
||||
const tilePopulation = w.population?.[i] || 0;
|
||||
const nearbyCampaigns = activeCampaignsNear(i);
|
||||
const nearbyNomad = activeNomadNear(i);
|
||||
els.tooltip.innerHTML = `
|
||||
<strong>${city ? `City #${city.id}` : terrain.name}</strong>
|
||||
${tooltipSection("Tile", [
|
||||
tooltipRow("Position", `${x}, ${y}`),
|
||||
tooltipRow("Terrain", terrain.name),
|
||||
tooltipRow("Resources", w.resource[i].toFixed(1)),
|
||||
tooltipRow("Fertility", w.fertility[i].toFixed(2)),
|
||||
tooltipRow("Minerals", w.mineral[i].toFixed(2)),
|
||||
tooltipRow("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`),
|
||||
tooltipRow("Water", waterInfluence),
|
||||
tooltipRow("Pressure", (w.populationPressure?.[i] ?? 0).toFixed(2)),
|
||||
tooltipRow("Capacity", (w.populationCapacity?.[i] ?? 0).toFixed(1)),
|
||||
tooltipRow("Pheromone", w.pheromone[i].toFixed(1)),
|
||||
tooltipRow("Route", w.tradeRoute[i], w.tradeRoute[i])
|
||||
])}
|
||||
${tooltipSection("Territory", [
|
||||
tooltipRow("Territory", territoryPolity ? `#${territoryPolity.id}` : "Unclaimed"),
|
||||
tooltipRow("Control", `${Math.round((w.control?.[i] || 0) * 100)}%`),
|
||||
tooltipRow("Contested", w.contested?.[i] ? "yes" : "no"),
|
||||
tooltipRow("Campaign", nearbyCampaigns, nearbyCampaigns),
|
||||
tooltipRow("Dominant ethnicity", regionalEthnicity),
|
||||
tooltipRow("Tile population", tilePopulation >= 10 ? Math.round(tilePopulation).toLocaleString() : tilePopulation.toFixed(1))
|
||||
])}
|
||||
${tooltipSection("Nomads", [
|
||||
tooltipRow("Band", nearbyNomad ? `${nearbyNomad.name} #${nearbyNomad.id}` : "", nearbyNomad),
|
||||
tooltipRow("Mode", nearbyNomad?.mode, nearbyNomad),
|
||||
tooltipRow("Population", nearbyNomad ? Math.round(nearbyNomad.population).toLocaleString() : "", nearbyNomad),
|
||||
tooltipRow("Ethnicity", nearbyNomad ? `E${nearbyNomad.ethnicityId}` : "", nearbyNomad),
|
||||
tooltipRow("Cohesion", nearbyNomad?.cohesion.toFixed(2), nearbyNomad),
|
||||
tooltipRow("Prestige", nearbyNomad?.prestige.toFixed(2), nearbyNomad),
|
||||
tooltipRow("Resources", nearbyNomad?.resources.toFixed(1), nearbyNomad),
|
||||
tooltipRow("Herds", nearbyNomad?.herds.toFixed(1), nearbyNomad)
|
||||
])}
|
||||
${tooltipSection("City & State", [
|
||||
tooltipRow("Population", city?.population.toLocaleString(), city),
|
||||
tooltipRow("Food stock", city?.storedResources.toFixed(1), city),
|
||||
tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city),
|
||||
tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city),
|
||||
tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city),
|
||||
tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity),
|
||||
city ? cityEthnicityPie(city) : "",
|
||||
tooltipRow("State", polity ? `#${polity.id}` : "Independent", city),
|
||||
tooltipRow("Loyalty", city?.loyalty.toFixed(2), city),
|
||||
tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity),
|
||||
tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity),
|
||||
tooltipRow("Treasury", polity?.treasury.toFixed(1), polity),
|
||||
tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity)
|
||||
])}
|
||||
${tooltipSection("Culture", [
|
||||
tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`),
|
||||
tooltipRow("Lineage pop", regionalLineage?.population?.toLocaleString(), regionalLineage),
|
||||
tooltipRow("Climate pref", regionalLineage ? `${regionalLineage.climateTemp.toFixed(2)} / ${regionalLineage.climateHumidity.toFixed(2)}` : "", regionalLineage),
|
||||
tooltipRow("Climate mismatch", mismatch.toFixed(2), regionalLineage),
|
||||
tooltipRow("Settled pop", (w.settledPopulation?.[i] || 0).toFixed(1), tilePopulation > 0),
|
||||
tooltipRow("Mobile pop", (w.mobilePopulation?.[i] || 0).toFixed(1), tilePopulation > 0),
|
||||
tooltipRow("Farming", (w.farmingKnowledge?.[i] || 0).toFixed(3)),
|
||||
tooltipRow("Metallurgy", (w.metallurgyKnowledge?.[i] || 0).toFixed(3))
|
||||
])}
|
||||
`;
|
||||
const territoryPolity = sim.getPolityById(w.territoryOwner[i]);
|
||||
const displayPolity = polity || territoryPolity;
|
||||
const mode = els.viewMode.value;
|
||||
const ethnicity = w.dominantEthnicity[i];
|
||||
const population = w.population[i] || 0;
|
||||
const rows = [
|
||||
tooltipRow("場所", `${terrain.name}(${x}, ${y})`),
|
||||
tooltipRow("所属", polity ? `国家${polity.id}${polity.centerCityId === city.id ? "・首都" : ""}` : city ? "独立都市" : territoryPolity ? `国家${territoryPolity.id}` : "国家未所属")
|
||||
];
|
||||
let citySection = "";
|
||||
if (city) {
|
||||
const cityRows = [
|
||||
tooltipRow("都市人口", city.population > 0 ? Math.round(city.population).toLocaleString() : "希薄"),
|
||||
tooltipRow("食料備蓄", city.storedResources.toFixed(1)),
|
||||
polity ? tooltipRow("忠誠度", `${Math.round((city.loyalty ?? 0.5) * 100)}%`) : ""
|
||||
];
|
||||
citySection = `<section><h3>都市データ</h3>${cityRows.filter(Boolean).join("")}${cityEthnicityPie(city)}</section>`;
|
||||
} else {
|
||||
rows.push(tooltipRow("地域人口", population > 0 ? Math.round(population).toLocaleString() : "希薄"));
|
||||
if (mode === "resources" || mode === "terrain") {
|
||||
rows.push(tooltipRow("資源", w.resource[i].toFixed(1)));
|
||||
rows.push(tooltipRow("肥沃度", `${Math.round(w.fertility[i] * 100)}%`));
|
||||
}
|
||||
}
|
||||
if (mode === "pressure") rows.push(tooltipRow("人口圧力", `${Math.round(w.populationPressure[i] * 100)}%(収容力比)`));
|
||||
if (mode === "ethnicity" && ethnicity >= 0) rows.push(tooltipRow("主な民族", `民族${ethnicity}`));
|
||||
if (mode === "technology") rows.push(tooltipRow("農業 / 冶金", `${Math.round(w.farmingKnowledge[i] * 100)}% / ${Math.round(w.metallurgyKnowledge[i] * 100)}%`));
|
||||
if (mode === "pheromone") rows.push(tooltipRow("交通量", w.pheromone[i].toFixed(1)));
|
||||
const campaign = activeCampaignsNear(i);
|
||||
if (campaign) rows.push(tooltipRow("遠征", campaign));
|
||||
else if (w.contested[i]) rows.push(tooltipRow("状態", "係争地"));
|
||||
const band = activeNomadNear(i);
|
||||
if (band) rows.push(tooltipRow("遊牧集団", `集団${band.id}・${displayTerm(band.mode)}`));
|
||||
let politySection = "";
|
||||
if (displayPolity) {
|
||||
const polityCities = sim.getPolityCities(displayPolity);
|
||||
const polityPopulation = sim.totalPolityPopulation(displayPolity);
|
||||
const institutionalAging = sim.polityInstitutionalAging ? sim.polityInstitutionalAging(displayPolity) : clamp(displayPolity.institutionalAging ?? 0, 0, 1);
|
||||
const loyaltyCap = sim.polityLoyaltyCap ? sim.polityLoyaltyCap(displayPolity) : clamp(1 - institutionalAging, 0, 1);
|
||||
politySection = tooltipSection("国家データ", [
|
||||
tooltipRow("国家", `国家${displayPolity.id}`),
|
||||
tooltipRow("国力", sim.polityPower(displayPolity).toFixed(1)),
|
||||
tooltipRow("人口", Math.round(polityPopulation).toLocaleString()),
|
||||
tooltipRow("都市数", polityCities.length.toLocaleString()),
|
||||
tooltipRow("国庫", Math.max(0, displayPolity.treasury || 0).toFixed(1)),
|
||||
tooltipRow("正統性 / 結束", `${Math.round((displayPolity.legitimacy ?? 0) * 100)}% / ${Math.round((displayPolity.cohesion ?? 0) * 100)}%`),
|
||||
tooltipRow("制度老朽化", `${Math.round(institutionalAging * 100)}%`),
|
||||
tooltipRow("忠誠度上限", `${Math.round(loyaltyCap * 100)}%`)
|
||||
]);
|
||||
}
|
||||
els.tooltip.innerHTML = `<strong>${city ? `都市${city.id}` : terrain.name}</strong>${rows.join("")}${citySection}${politySection}`;
|
||||
els.tooltip.hidden = false;
|
||||
const margin = 8;
|
||||
const width = els.tooltip.offsetWidth;
|
||||
|
|
@ -470,7 +474,7 @@ return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
|
|||
const labels = slices.map(([id, count]) => {
|
||||
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
||||
const color = ethnicity?.color || [122, 130, 126];
|
||||
const label = id ? `E${id}` : "Other";
|
||||
const label = id ? `民族${id}` : "その他";
|
||||
return `<span class="ethnicity-pie-key"><i style="background: rgb(${color.join(",")})"></i>${label} ${Math.round(count / total * 100)}%</span>`;
|
||||
}).join("");
|
||||
return `
|
||||
|
|
@ -497,9 +501,9 @@ const cy = Math.floor(campaign.targetTile / w.size);
|
|||
const nearTarget = Math.abs(cx - x) + Math.abs(cy - y) <= 3;
|
||||
const nearFront = campaign.affectedTiles?.includes(tile);
|
||||
if (!nearTarget && !nearFront) continue;
|
||||
labels.push(`${campaign.type.replaceAll("_", " ")} ${Math.round(clamp(campaign.progress, 0, 1) * 100)}%`);
|
||||
labels.push(`${displayTerm(campaign.type)} ${Math.round(clamp(campaign.progress, 0, 1) * 100)}%`);
|
||||
}
|
||||
return labels.slice(0, 2).join("; ");
|
||||
return labels.slice(0, 2).join("、");
|
||||
}
|
||||
function activeNomadNear(tile) {
|
||||
if (!sim?.nomadBands?.length) return null;
|
||||
|
|
@ -519,68 +523,24 @@ bestDistance = distance;
|
|||
}
|
||||
return best;
|
||||
}
|
||||
function waterInfluenceAt(world, x, y) {
|
||||
let score = 0;
|
||||
for (let dy = -3; dy <= 3; dy++) {
|
||||
for (let dx = -3; dx <= 3; dx++) {
|
||||
const d = Math.abs(dx) + Math.abs(dy);
|
||||
if (!d || d > 3) continue;
|
||||
const tx = x + dx;
|
||||
const ty = y + dy;
|
||||
if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue;
|
||||
if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4;
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
function updateStats(force = false) {
|
||||
const now = performance.now();
|
||||
if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return;
|
||||
lastStatsAt = now;
|
||||
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
|
||||
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
|
||||
let farmingTotal = 0;
|
||||
let metallurgyTotal = 0;
|
||||
let farmingHolders = 0;
|
||||
let metallurgyHolders = 0;
|
||||
for (let i = 0; i < sim.world.count; i++) {
|
||||
const pop = sim.world.population?.[i] || 0;
|
||||
if (pop <= 0) continue;
|
||||
farmingTotal += (sim.world.farmingKnowledge?.[i] || 0) * pop;
|
||||
metallurgyTotal += (sim.world.metallurgyKnowledge?.[i] || 0) * pop;
|
||||
if ((sim.world.farmingKnowledge?.[i] || 0) > 0.02) farmingHolders++;
|
||||
if ((sim.world.metallurgyKnowledge?.[i] || 0) > 0.02) metallurgyHolders++;
|
||||
}
|
||||
const fieldPopulation = sim.world.population
|
||||
? Array.from(sim.world.population).reduce((sum, value) => sum + value, 0)
|
||||
: 0;
|
||||
const knowledgeDenominator = Math.max(1, fieldPopulation);
|
||||
const stats = {
|
||||
year: formatSimDate(sim.year),
|
||||
activeGroups: Math.round(fieldPopulation).toLocaleString(),
|
||||
urbanPopulation: Math.floor(urbanPopulation).toLocaleString(),
|
||||
ethnicities: livingEthnicities.length.toLocaleString(),
|
||||
cities: sim.cities.length.toLocaleString(),
|
||||
polities: sim.polities.length.toLocaleString(),
|
||||
wars: sim.wars.length.toLocaleString(),
|
||||
routes: sim.tradeLinks.length.toLocaleString(),
|
||||
farmingKnowledge: `${(farmingTotal / knowledgeDenominator).toFixed(4)} (${farmingHolders})`,
|
||||
metallurgyKnowledge: `${(metallurgyTotal / knowledgeDenominator).toFixed(4)} (${metallurgyHolders})`,
|
||||
deaths: sim.deaths.toLocaleString()
|
||||
};
|
||||
for (const [id, value] of Object.entries(stats)) {
|
||||
if (els[id]) els[id].textContent = value;
|
||||
}
|
||||
if (els.currentDate) els.currentDate.textContent = formatSimDate(sim.year);
|
||||
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
|
||||
els.ethnicityList.innerHTML = top.map(e => (
|
||||
if (els.ethnicityList) els.ethnicityList.innerHTML = top.map(e => (
|
||||
`<li><span class="lineage-chip" style="background: rgb(${e.color.join(",")})"></span>` +
|
||||
`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}</li>`
|
||||
`民族${e.id} 人口 ${Math.round(e.population).toLocaleString()}<br>多様性 ${Math.round(e.diversity * 100)}% 親系統 ${e.parent ? `民族${e.parent}` : "なし"}</li>`
|
||||
)).join("");
|
||||
maybeRenderStateGraph();
|
||||
}
|
||||
function setLegend() {
|
||||
const mode = els.viewMode.value;
|
||||
els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)();
|
||||
const content = (legendByMode[mode] || legendByMode.resources)();
|
||||
els.legend.innerHTML = content;
|
||||
els.legend.hidden = !content;
|
||||
}
|
||||
function maybeRenderStateGraph() {
|
||||
const now = performance.now();
|
||||
|
|
@ -626,10 +586,18 @@ if (!rect.width) return;
|
|||
const historyWindowSpan = years(SimConfig.render.historyWindowYears);
|
||||
const historyWindowEnd = sim.year;
|
||||
const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan);
|
||||
renderWorldTrendGraph(historyWindowStart, historyWindowEnd);
|
||||
graphState.filter = els.historyFilter?.value || graphState.filter;
|
||||
graphState.sort = els.historySort?.value || graphState.sort;
|
||||
graphState.metric = els.historyMetric?.value || graphState.metric;
|
||||
const histories = sim.getAllPolityHistories()
|
||||
const allHistories = sim.getAllPolityHistories();
|
||||
const recentEndedIds = new Set(allHistories
|
||||
.filter(history => !(history.active && history.ended === null))
|
||||
.sort((a, b) => (b.ended ?? 0) - (a.ended ?? 0))
|
||||
.slice(0, 20)
|
||||
.map(history => history.id));
|
||||
const histories = allHistories
|
||||
.filter(history => (history.active && history.ended === null) || recentEndedIds.has(history.id))
|
||||
.filter(history => (history.ended ?? sim.year) >= historyWindowStart)
|
||||
.map(history => ({
|
||||
...history,
|
||||
|
|
@ -682,11 +650,11 @@ const activeRows = selected.filter(item => item.history.active && item.history.e
|
|||
const pastRows = selected.filter(item => !(item.history.active && item.history.ended === null));
|
||||
const displayRows = [];
|
||||
if (graphState.showActive && graphState.filter !== "ended" && activeRows.length) {
|
||||
displayRows.push({ type: "group", label: `Living ${activeRows.length}` });
|
||||
displayRows.push({ type: "group", label: `存続中 ${activeRows.length}` });
|
||||
for (const item of activeRows) displayRows.push({ type: "state", item });
|
||||
}
|
||||
if (graphState.showPast && graphState.filter !== "active" && pastRows.length) {
|
||||
displayRows.push({ type: "group", label: `Past ${pastRows.length}` });
|
||||
displayRows.push({ type: "group", label: `滅亡済み ${pastRows.length}` });
|
||||
for (const item of pastRows) displayRows.push({ type: "state", item });
|
||||
}
|
||||
const logicalHeight = paddingTop +
|
||||
|
|
@ -715,7 +683,8 @@ graphState.markers = [];
|
|||
graphState.scale = null;
|
||||
g.fillStyle = "#7f8984";
|
||||
g.font = "12px ui-sans-serif, system-ui, sans-serif";
|
||||
g.fillText("No state history yet", 16, 28);
|
||||
g.fillText("国家の歴史はまだありません", 16, 28);
|
||||
setEventLegend();
|
||||
return;
|
||||
}
|
||||
let minYear = historyWindowStart;
|
||||
|
|
@ -786,17 +755,20 @@ g.textAlign = "center";
|
|||
g.fillText(formatGraphYear((minYear + maxYear) / 2), paddingLeft + plotWidth / 2, h - 5);
|
||||
g.textAlign = "right";
|
||||
g.fillText(formatGraphYear(maxYear), w - paddingRight, h - 5);
|
||||
const topEvents = (sim.graphEvents || [])
|
||||
const topEvents = graphState.showEventIcons ? (sim.graphEvents || [])
|
||||
.filter(event => event.importance >= 2)
|
||||
.filter(event => event.year >= minYear && event.year <= maxYear)
|
||||
.filter(event => graphEventTypes[event.type]);
|
||||
.filter(event => event.type !== "war" || event.data?.outcome === "started")
|
||||
.filter(event => graphEventTypes[event.type]) : [];
|
||||
const topCollisions = new Map();
|
||||
for (const event of topEvents) {
|
||||
const x = yearToX(event.year);
|
||||
const bucket = Math.round(x / 7);
|
||||
const rawX = yearToX(event.year);
|
||||
const bucket = Math.round(rawX / 18);
|
||||
const count = topCollisions.get(bucket) || 0;
|
||||
topCollisions.set(bucket, count + 1);
|
||||
const y = Math.max(7, paddingTop * 0.5 + ([-3, 0, 3][count % 3]));
|
||||
const point = spreadGraphMarker(rawX, paddingTop * 0.5, count, paddingLeft, w - paddingRight);
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
|
||||
const graphEvent = graphEventTypes[event.type];
|
||||
g.fillStyle = graphEvent.color;
|
||||
|
|
@ -821,7 +793,7 @@ const history = row.history;
|
|||
g.textAlign = "left";
|
||||
g.fillStyle = "#a8b1aa";
|
||||
g.font = "9px ui-sans-serif, system-ui, sans-serif";
|
||||
g.fillText(`S${history.id}`, 4, row.y + 3);
|
||||
g.fillText(`国${history.id}`, 4, row.y + 3);
|
||||
const inactiveMultiplier = history.active ? 1 : 0.65;
|
||||
const strokeSegment = (fromYear, toYear, metricValue, cities, alphaScale = 1) => {
|
||||
if (toYear <= fromYear) return;
|
||||
|
|
@ -869,17 +841,20 @@ strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValu
|
|||
});
|
||||
rows.forEach(row => {
|
||||
if (row.type !== "state") return;
|
||||
const events = (row.history.events || [])
|
||||
const events = graphState.showEventIcons ? (row.history.events || [])
|
||||
.filter(event => event.importance >= 2)
|
||||
.filter(event => event.year >= minYear && event.year <= maxYear)
|
||||
.filter(event => graphEventTypes[event.type]);
|
||||
.filter(event => event.type !== "war" || event.data?.outcome === "started")
|
||||
.filter(event => graphEventTypes[event.type]) : [];
|
||||
const collisions = new Map();
|
||||
for (const event of events) {
|
||||
const x = yearToX(event.year);
|
||||
const bucket = Math.round(x / 7);
|
||||
const rawX = yearToX(event.year);
|
||||
const bucket = Math.round(rawX / 18);
|
||||
const count = collisions.get(bucket) || 0;
|
||||
collisions.set(bucket, count + 1);
|
||||
const y = row.y + ([-4, 0, 4][count % 3]);
|
||||
const point = spreadGraphMarker(rawX, row.y, count, paddingLeft, w - paddingRight);
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
|
||||
const graphEvent = graphEventTypes[event.type];
|
||||
g.fillStyle = graphEvent.color;
|
||||
|
|
@ -887,6 +862,113 @@ graphEvent.draw(g, x, y, r);
|
|||
graphState.markers.push({ row, event, x, y, radius: r + 3 });
|
||||
}
|
||||
});
|
||||
setEventLegend();
|
||||
}
|
||||
function renderWorldTrendGraph(minYear, maxYear) {
|
||||
const canvas = els.worldTrendGraph;
|
||||
if (!canvas || !sim) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const logicalHeight = 176;
|
||||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||
const height = Math.max(1, Math.floor(logicalHeight * dpr));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
const g = canvas.getContext("2d");
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = rect.width;
|
||||
g.clearRect(0, 0, w, logicalHeight);
|
||||
g.fillStyle = "#101315";
|
||||
g.fillRect(0, 0, w, logicalHeight);
|
||||
const samples = (sim.worldHistory || []).filter(sample => sample.year >= minYear && sample.year <= maxYear);
|
||||
if (!samples.length) {
|
||||
g.fillStyle = "#7f8984";
|
||||
g.font = "11px ui-sans-serif, system-ui, sans-serif";
|
||||
g.fillText("推移データはまだありません", 8, 20);
|
||||
return;
|
||||
}
|
||||
const left = 7;
|
||||
const right = 7;
|
||||
const plotWidth = Math.max(1, w - left - right);
|
||||
const span = Math.max(1, maxYear - minYear);
|
||||
const xAt = sample => left + ((sample.year - minYear) / span) * plotWidth;
|
||||
const regional = samples.map(sample => Math.max(0, sample.regionalPopulation || 0));
|
||||
const urban = samples.map(sample => Math.max(0, sample.urbanPopulation || 0));
|
||||
const populationMaximum = Math.max(1, ...regional.map((value, index) => value + urban[index]));
|
||||
const populationTop = 22;
|
||||
const populationBaseline = 91;
|
||||
const populationHeight = populationBaseline - populationTop;
|
||||
g.strokeStyle = "rgba(168, 177, 170, 0.13)";
|
||||
g.lineWidth = 1;
|
||||
strokeLine(g, left, populationBaseline, w - right, populationBaseline);
|
||||
const populationY = value => populationBaseline - value / populationMaximum * populationHeight;
|
||||
g.beginPath();
|
||||
g.moveTo(xAt(samples[0]), populationBaseline);
|
||||
for (let index = 0; index < samples.length; index++) g.lineTo(xAt(samples[index]), populationY(regional[index]));
|
||||
g.lineTo(xAt(samples[samples.length - 1]), populationBaseline);
|
||||
g.closePath();
|
||||
g.fillStyle = "rgba(117, 181, 126, 0.42)";
|
||||
g.fill();
|
||||
g.beginPath();
|
||||
g.moveTo(xAt(samples[0]), populationY(regional[0]));
|
||||
for (let index = 0; index < samples.length; index++) g.lineTo(xAt(samples[index]), populationY(regional[index] + urban[index]));
|
||||
for (let index = samples.length - 1; index >= 0; index--) g.lineTo(xAt(samples[index]), populationY(regional[index]));
|
||||
g.closePath();
|
||||
g.fillStyle = "rgba(227, 198, 116, 0.58)";
|
||||
g.fill();
|
||||
g.fillStyle = "#9bc89b";
|
||||
g.font = "10px ui-sans-serif, system-ui, sans-serif";
|
||||
g.textAlign = "left";
|
||||
g.fillText(`地域人口 ${Math.round(regional.at(-1)).toLocaleString()}`, 7, 12);
|
||||
g.fillStyle = "#e3c674";
|
||||
g.fillText(`都市人口 ${Math.round(urban.at(-1)).toLocaleString()}`, Math.max(112, w * 0.5), 12);
|
||||
const countMetrics = [
|
||||
{ key: "cities", label: "都市", color: "#77b9d6" },
|
||||
{ key: "polities", label: "国家", color: "#d58b84" },
|
||||
{ key: "ethnicities", label: "民族", color: "#b49bd6" }
|
||||
];
|
||||
const countTop = 112;
|
||||
const countBaseline = 169;
|
||||
const countHeight = countBaseline - countTop;
|
||||
const countMaximum = Math.max(1, ...countMetrics.flatMap(metric => samples.map(sample => Math.max(0, sample[metric.key] || 0))));
|
||||
strokeLine(g, left, countBaseline, w - right, countBaseline);
|
||||
for (let metricIndex = 0; metricIndex < countMetrics.length; metricIndex++) {
|
||||
const metric = countMetrics[metricIndex];
|
||||
const values = samples.map(sample => Math.max(0, sample[metric.key] || 0));
|
||||
g.fillStyle = metric.color;
|
||||
g.fillText(`${metric.label} ${Math.round(values.at(-1)).toLocaleString()}`, 7 + metricIndex * Math.max(78, (w - 14) / 3), 105);
|
||||
g.beginPath();
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
const x = xAt(samples[index]);
|
||||
const y = countBaseline - values[index] / countMaximum * countHeight;
|
||||
if (index === 0) g.moveTo(x, y);
|
||||
else g.lineTo(x, y);
|
||||
}
|
||||
g.strokeStyle = metric.color;
|
||||
g.lineWidth = 1.6;
|
||||
g.stroke();
|
||||
const lastX = xAt(samples.at(-1));
|
||||
const lastY = countBaseline - values.at(-1) / countMaximum * countHeight;
|
||||
g.beginPath();
|
||||
g.arc(lastX, lastY, 2, 0, Math.PI * 2);
|
||||
g.fillStyle = metric.color;
|
||||
g.fill();
|
||||
}
|
||||
g.textAlign = "left";
|
||||
}
|
||||
function spreadGraphMarker(x, baseY, count, minX, maxX) {
|
||||
const xOffsets = [0, -8, 8, -16, 16, -24, 24];
|
||||
const yOffsets = [0, -6, 6];
|
||||
const positionsPerBand = xOffsets.length * yOffsets.length;
|
||||
const band = Math.floor(count / positionsPerBand);
|
||||
const position = count % positionsPerBand;
|
||||
const xOffset = xOffsets[Math.floor(position / yOffsets.length)];
|
||||
const yOffset = yOffsets[position % yOffsets.length];
|
||||
const bandOffset = band ? (band * 3) * (x < (minX + maxX) * 0.5 ? 1 : -1) : 0;
|
||||
return { x: clamp(x + xOffset + bandOffset, minX, maxX), y: baseY + yOffset };
|
||||
}
|
||||
function drawGraphCircle(g, x, y, r) {
|
||||
g.beginPath();
|
||||
|
|
@ -910,143 +992,82 @@ function drawGraphTriangleDown(g, x, y, r) {
|
|||
drawGraphPolygon(g, [[x, y + r], [x + r, y - r], [x - r, y - r]]);
|
||||
}
|
||||
const graphEventTypes = {
|
||||
capitalShift: {
|
||||
label: "capital shift",
|
||||
color: "rgba(227, 179, 65, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`
|
||||
},
|
||||
disaster: {
|
||||
label: "major disaster",
|
||||
color: "rgba(211, 95, 84, 0.95)",
|
||||
draw: drawGraphCircle,
|
||||
summary: data => `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`
|
||||
},
|
||||
famine: {
|
||||
label: "famine",
|
||||
color: "rgba(227, 128, 65, 0.90)",
|
||||
draw: drawGraphTriangleDown,
|
||||
summary: data => `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`
|
||||
},
|
||||
rebellion: {
|
||||
label: "rebellion",
|
||||
color: "rgba(236, 126, 111, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => data.cityCount > 1
|
||||
? `${data.cityCount} cities, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}`
|
||||
: `city #${data.cityId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}`
|
||||
},
|
||||
newEthnicity: {
|
||||
label: "new ethnicity",
|
||||
color: "rgba(105, 181, 120, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier population`
|
||||
},
|
||||
campaign: {
|
||||
label: "campaign",
|
||||
color: "rgba(232, 205, 116, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => `${data.outcome || "event"} ${data.campaignType || data.targetType || "target"}`
|
||||
},
|
||||
expansion: {
|
||||
label: "expansion",
|
||||
color: "rgba(210, 194, 104, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `${data.outcome || "event"} ${data.campaignType || "frontier"}, tile ${data.targetTile ?? "-"}`
|
||||
},
|
||||
colonization: {
|
||||
label: "colonization",
|
||||
color: "rgba(232, 205, 116, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `${data.campaignType || "frontier"}, progress ${Math.round((data.progress || 0) * 100)}%`
|
||||
},
|
||||
subjugation: {
|
||||
label: "subjugation",
|
||||
color: "rgba(196, 122, 77, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => `${data.campaignType || "non-state"}, tile ${data.targetTile ?? "-"}`
|
||||
},
|
||||
annexation: {
|
||||
label: "annexation",
|
||||
color: "rgba(227, 179, 65, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `city #${data.targetCityId ?? "-"}`
|
||||
},
|
||||
failedCampaign: {
|
||||
label: "failed campaign",
|
||||
color: "rgba(151, 110, 91, 0.95)",
|
||||
draw: drawGraphTriangleDown,
|
||||
summary: data => `${data.outcome || "failed"} ${data.campaignType || "campaign"}`
|
||||
},
|
||||
borderWar: {
|
||||
label: "border war",
|
||||
color: "rgba(236, 126, 111, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => `${data.outcome || "war"} vs S${data.targetPolityId ?? "-"}`
|
||||
},
|
||||
nomadBand: {
|
||||
label: "nomad band",
|
||||
color: "rgba(210, 184, 104, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `band #${data.bandId ?? "-"}, E${data.ethnicity ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}`
|
||||
},
|
||||
nomadTrade: {
|
||||
label: "nomad trade",
|
||||
color: "rgba(216, 177, 86, 0.95)",
|
||||
draw: drawGraphCircle,
|
||||
summary: data => `band #${data.bandId ?? "-"} with city #${data.cityId ?? "-"}`
|
||||
},
|
||||
nomadRaid: {
|
||||
label: "nomad raid",
|
||||
color: "rgba(232, 101, 76, 0.95)",
|
||||
draw: drawGraphTriangleDown,
|
||||
summary: data => `band #${data.bandId ?? "-"} raided city #${data.cityId ?? "-"}`
|
||||
},
|
||||
nomadConquest: {
|
||||
label: "nomad conquest",
|
||||
color: "rgba(202, 87, 78, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => `band #${data.bandId ?? "-"}, city #${data.cityId ?? "-"}, ${data.outcome || "conquest"}`
|
||||
},
|
||||
nomadConfederation: {
|
||||
label: "nomad confederation",
|
||||
color: "rgba(191, 162, 91, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `band #${data.bandId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}`
|
||||
},
|
||||
nomadSplit: {
|
||||
label: "nomad split",
|
||||
color: "rgba(151, 130, 91, 0.95)",
|
||||
draw: drawGraphTriangleDown,
|
||||
summary: data => `band #${data.bandId ?? "-"} split #${data.newBandId ?? "-"}`
|
||||
},
|
||||
nomadMerge: {
|
||||
label: "nomad merge",
|
||||
color: "rgba(170, 145, 91, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `band #${data.mergedBandId ?? "-"} joined #${data.bandId ?? "-"}`
|
||||
},
|
||||
sedentarization: {
|
||||
label: "sedentarization",
|
||||
color: "rgba(202, 169, 102, 0.95)",
|
||||
draw: drawGraphCircle,
|
||||
summary: data => `band #${data.bandId ?? "-"} settled ${Math.round(data.population || 0).toLocaleString()} in city #${data.cityId ?? "-"}`
|
||||
},
|
||||
war: {
|
||||
label: "war",
|
||||
color: "rgba(202, 87, 78, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => `${data.outcome || "war"} ${data.campaignType || ""}`
|
||||
},
|
||||
collapse: {
|
||||
label: "collapse",
|
||||
color: "rgba(130, 136, 139, 0.95)",
|
||||
draw: drawGraphTriangleDown,
|
||||
summary: data => data.reason || "state ended"
|
||||
}
|
||||
capitalShift: { label: "遷都", color: "rgba(227, 179, 65, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("capitalShift", data) },
|
||||
disaster: { label: "大災害", color: "rgba(211, 95, 84, 0.95)", draw: drawGraphCircle, summary: data => summarizeEvent("disaster", data) },
|
||||
famine: { label: "飢饉", color: "rgba(227, 128, 65, 0.90)", draw: drawGraphTriangleDown, summary: data => summarizeEvent("famine", data) },
|
||||
rebellion: { label: "反乱", color: "rgba(236, 126, 111, 0.95)", draw: drawGraphTriangleUp, summary: data => summarizeEvent("rebellion", data) },
|
||||
institutionalReform: { label: "制度改革", color: "rgba(111, 173, 206, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("institutionalReform", data) },
|
||||
newEthnicity: { label: "新しい民族", color: "rgba(105, 181, 120, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("newEthnicity", data) },
|
||||
campaign: { label: "遠征", color: "rgba(232, 205, 116, 0.95)", draw: drawGraphTriangleUp, summary: data => summarizeEvent("campaign", data) },
|
||||
expansion: { label: "領土拡大", color: "rgba(210, 194, 104, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("expansion", data) },
|
||||
colonization: { label: "開拓", color: "rgba(232, 205, 116, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("colonization", data) },
|
||||
subjugation: { label: "征服", color: "rgba(196, 122, 77, 0.95)", draw: drawGraphTriangleUp, summary: data => summarizeEvent("subjugation", data) },
|
||||
annexation: { label: "都市の併合", color: "rgba(227, 179, 65, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("annexation", data) },
|
||||
failedCampaign: { label: "遠征失敗", color: "rgba(151, 110, 91, 0.95)", draw: drawGraphTriangleDown, summary: data => summarizeEvent("failedCampaign", data) },
|
||||
borderWar: { label: "国境戦争", color: "rgba(236, 126, 111, 0.95)", draw: drawGraphTriangleUp, summary: data => summarizeEvent("borderWar", data) },
|
||||
nomadBand: { label: "遊牧集団の誕生", color: "rgba(210, 184, 104, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("nomadBand", data) },
|
||||
nomadTrade: { label: "遊牧集団との交易", color: "rgba(216, 177, 86, 0.95)", draw: drawGraphCircle, summary: data => summarizeEvent("nomadTrade", data) },
|
||||
nomadRaid: { label: "遊牧集団の襲撃", color: "rgba(232, 101, 76, 0.95)", draw: drawGraphTriangleDown, summary: data => summarizeEvent("nomadRaid", data) },
|
||||
nomadConquest: { label: "遊牧集団の征服", color: "rgba(202, 87, 78, 0.95)", draw: drawGraphTriangleUp, summary: data => summarizeEvent("nomadConquest", data) },
|
||||
nomadConfederation: { label: "遊牧連合", color: "rgba(191, 162, 91, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("nomadConfederation", data) },
|
||||
nomadSplit: { label: "遊牧集団の分裂", color: "rgba(151, 130, 91, 0.95)", draw: drawGraphTriangleDown, summary: data => summarizeEvent("nomadSplit", data) },
|
||||
nomadMerge: { label: "遊牧集団の合流", color: "rgba(170, 145, 91, 0.95)", draw: drawGraphDiamond, summary: data => summarizeEvent("nomadMerge", data) },
|
||||
sedentarization: { label: "遊牧集団の定住", color: "rgba(202, 169, 102, 0.95)", draw: drawGraphCircle, summary: data => summarizeEvent("sedentarization", data) },
|
||||
war: { label: "戦争", color: "rgba(202, 87, 78, 0.95)", draw: drawGraphTriangleUp, summary: data => summarizeEvent("war", data) },
|
||||
collapse: { label: "国家の崩壊", color: "rgba(130, 136, 139, 0.95)", draw: drawGraphTriangleDown, summary: data => summarizeEvent("collapse", data) }
|
||||
};
|
||||
function summarizeEvent(type, data) {
|
||||
if (type === "capitalShift") return `都市${data.oldCenterCityId ?? "-"} → 都市${data.newCenterCityId ?? "-"}`;
|
||||
if (type === "disaster") return `人口損失 ${Math.round((data.lossRate || 0) * 100)}%・死者 ${Math.round(data.populationLoss || 0).toLocaleString()}人`;
|
||||
if (type === "famine") return `食料不足の都市 ${Math.round((data.poorRate || 0) * 100)}%`;
|
||||
if (type === "institutionalReform") {
|
||||
const before = Math.round((data.before || 0) * 100);
|
||||
const after = Math.round((data.after || 0) * 100);
|
||||
const change = Math.round((data.changeRate || 0) * 100);
|
||||
return `制度老朽化 ${before}% → ${after}%(${change >= 0 ? "+" : ""}${change}%)`;
|
||||
}
|
||||
const details = [];
|
||||
if (data.outcome || data.reason) details.push(displayTerm(data.outcome || data.reason));
|
||||
if (data.campaignType) details.push(displayTerm(data.campaignType));
|
||||
if (data.bandId != null) details.push(`遊牧集団${data.bandId}`);
|
||||
if (data.cityId != null || data.targetCityId != null) details.push(`都市${data.cityId ?? data.targetCityId}`);
|
||||
if (data.targetPolityId != null) details.push(`相手国${data.targetPolityId}`);
|
||||
if (data.ethnicity != null) details.push(`民族${data.ethnicity}`);
|
||||
if (data.population != null) details.push(`人口 ${Math.round(data.population).toLocaleString()}`);
|
||||
if (data.cityCount != null) details.push(`${data.cityCount}都市`);
|
||||
if (data.newBandId != null) details.push(`新集団${data.newBandId}`);
|
||||
if (data.mergedBandId != null) details.push(`合流元${data.mergedBandId}`);
|
||||
if (data.progress != null) details.push(`進捗 ${Math.round(data.progress * 100)}%`);
|
||||
return details.join("・");
|
||||
}
|
||||
function setEventLegend() {
|
||||
if (!els.eventLegend) return;
|
||||
if (!graphState.showEventIcons) {
|
||||
els.eventLegend.setAttribute("data-event-types", "hidden");
|
||||
els.eventLegend.innerHTML = '<p class="event-legend-empty">出来事アイコンは非表示です。</p>';
|
||||
return;
|
||||
}
|
||||
const types = [...new Set(graphState.markers.map(marker => marker.event.type))]
|
||||
.filter(type => graphEventTypes[type]);
|
||||
const key = types.join(",");
|
||||
if (els.eventLegend.getAttribute("data-event-types") === key) return;
|
||||
els.eventLegend.setAttribute("data-event-types", key);
|
||||
if (!types.length) {
|
||||
els.eventLegend.innerHTML = '<p class="event-legend-empty">現在、歴史グラフに出来事の記号は表示されていません。</p>';
|
||||
return;
|
||||
}
|
||||
els.eventLegend.innerHTML = types.map(type =>
|
||||
`<span><canvas width="20" height="20" data-event-type="${type}" aria-hidden="true"></canvas>${graphEventTypes[type].label}</span>`
|
||||
).join("");
|
||||
for (const canvas of els.eventLegend.querySelectorAll("canvas")) {
|
||||
const event = graphEventTypes[canvas.getAttribute("data-event-type")];
|
||||
const g = canvas.getContext("2d");
|
||||
g.fillStyle = event.color;
|
||||
event.draw(g, 10, 10, 5);
|
||||
}
|
||||
}
|
||||
function graphEventLabel(event) {
|
||||
return graphEventTypes[event.type]?.label || event.type;
|
||||
return graphEventTypes[event.type]?.label || "出来事";
|
||||
}
|
||||
function graphEventSummary(event) {
|
||||
return graphEventTypes[event.type]?.summary(event.data || {}) || "";
|
||||
|
|
@ -1054,7 +1075,7 @@ return graphEventTypes[event.type]?.summary(event.data || {}) || "";
|
|||
function showStateGraphMarkerTooltip(marker, pointerX, pointerY) {
|
||||
if (!els.stateGraphTooltip) return;
|
||||
const title = marker.row
|
||||
? `S${marker.row.history.id} ${graphEventLabel(marker.event)}`
|
||||
? `国家${marker.row.history.id} ${graphEventLabel(marker.event)}`
|
||||
: graphEventLabel(marker.event);
|
||||
els.stateGraphTooltip.innerHTML = `
|
||||
<strong>${title}</strong>
|
||||
|
|
@ -1090,8 +1111,8 @@ if (!running) render();
|
|||
}
|
||||
return;
|
||||
}
|
||||
const status = nearestMarker.row.history.active && nearestMarker.row.history.ended === null ? "living" : "past";
|
||||
els.stateGraphInfo.textContent = `S${nearestMarker.row.history.id} ${status} ${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
|
||||
const status = nearestMarker.row.history.active && nearestMarker.row.history.ended === null ? "存続中" : "滅亡済み";
|
||||
els.stateGraphInfo.textContent = `国家${nearestMarker.row.history.id} ${status} ${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
|
||||
showStateGraphMarkerTooltip(nearestMarker, x, y);
|
||||
if (graphState.hoverPolityId !== nearestMarker.row.history.id) {
|
||||
graphState.hoverPolityId = nearestMarker.row.history.id;
|
||||
|
|
@ -1104,7 +1125,7 @@ const row = graphState.rows
|
|||
.filter(item => item.distance <= item.candidate.height * 0.5);
|
||||
const nearestRow = bestBy(row, item => -item.distance)?.candidate;
|
||||
if (!nearestRow) {
|
||||
els.stateGraphInfo.textContent = "Hover a row for details";
|
||||
els.stateGraphInfo.textContent = "";
|
||||
hideStateGraphMarkerTooltip();
|
||||
if (graphState.hoverPolityId !== null) {
|
||||
graphState.hoverPolityId = null;
|
||||
|
|
@ -1127,16 +1148,13 @@ power: nearestRow.peakPower
|
|||
}];
|
||||
const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year));
|
||||
const metricValue = graphSampleValue(sample, graphState.metric);
|
||||
const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past";
|
||||
const status = nearestRow.history.active && nearestRow.history.ended === null ? "存続中" : "滅亡済み";
|
||||
els.stateGraphInfo.textContent =
|
||||
`S${nearestRow.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` +
|
||||
`${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` +
|
||||
`pop ${Math.round(sample.population || 0).toLocaleString()} ` +
|
||||
`cities ${Math.round(sample.cities || 0).toLocaleString()} ` +
|
||||
`loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`;
|
||||
`国家${nearestRow.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` +
|
||||
`${({power: "国力", population: "人口", cities: "都市数", loyalty: "忠誠度"})[graphState.metric]} ${formatGraphValue(metricValue, graphState.metric)}`;
|
||||
}
|
||||
function clearStateGraphInfo() {
|
||||
if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details";
|
||||
if (els.stateGraphInfo) els.stateGraphInfo.textContent = "";
|
||||
hideStateGraphMarkerTooltip();
|
||||
if (graphState.hoverPolityId !== null) {
|
||||
graphState.hoverPolityId = null;
|
||||
|
|
@ -1144,12 +1162,12 @@ if (!running) render();
|
|||
}
|
||||
}
|
||||
function formatGraphYear(week) {
|
||||
return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`;
|
||||
return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}年`;
|
||||
}
|
||||
function formatSimDate(week) {
|
||||
const year = Math.floor(week / WEEKS_PER_YEAR);
|
||||
const weekOfYear = week % WEEKS_PER_YEAR;
|
||||
const monthOfYear = Math.floor(weekOfYear / WEEKS_PER_MONTH) + 1;
|
||||
const weekOfMonth = weekOfYear % WEEKS_PER_MONTH + 1;
|
||||
return `${year.toLocaleString()}y ${monthOfYear}m ${weekOfMonth}w`;
|
||||
return `${year.toLocaleString()}年 ${monthOfYear}月 第${weekOfMonth}週`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue