civ/render.js
2026-09-16 20:53:17 +09:00

1173 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
const size = w.size;
if (!renderImage || renderImageSize !== size) {
renderImage = ctx.createImageData(size, size);
renderImageSize = size;
}
const image = renderImage;
const data = image.data;
const mode = els.viewMode.value;
const polityColors = new Map();
if (mode === "polities") {
for (const polity of sim.polities) {
polityColors.set(polity.id, {
color: polity.color,
isGraphHover: graphState.hoverPolityId === polity.id
});
}
}
for (let i = 0; i < w.count; i++) {
let color;
if (mode === "resources") {
const v = clamp(w.resource[i] / 30, 0, 1);
color = mix([28, 36, 40], [107, 188, 85], v);
} else if (mode === "ethnicity") {
color = terrainInfo[w.terrain[i]].color;
const ethnicMix = sim.tileEthnicMix?.get(i);
const population = w.population?.[i] || 0;
if (ethnicMix && ethnicMix.size && population >= 0.25) {
const dominant = dominantComposition(ethnicMix);
const ethnicityColor = dominant != null ? sim.ethnicities.get(dominant)?.color : null;
const total = compositionTotal(ethnicMix);
if (ethnicityColor && Number.isFinite(total) && total > 0) {
const dominantShare = (ethnicMix.get(dominant) || 0) / total;
const popStrength = clamp(Math.log1p(population) / Math.log(80), 0.05, 0.72);
const purity = clamp(dominantShare, 0.25, 1);
const strength = clamp(popStrength * (0.45 + purity * 0.55), 0.04, 0.72);
color = mix(color, ethnicityColor, strength);
}
}
if (w.city[i] >= 0) {
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] || 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.territoryOwner[i];
if (territory >= 0) {
const polityColor = polityColors.get(territory);
if (polityColor) {
const strength = clamp(0.16 + (w.control?.[i] || 0) * 0.38, 0.18, 0.55);
color = mix(color, polityColor.color, polityColor.isGraphHover ? Math.min(0.72, strength + 0.18) : strength);
if (w.contested?.[i]) color = mix(color, [128, 74, 50], 0.30);
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);
}
}
} else if (mode === "technology") {
const farming = w.farmingKnowledge?.[i] || 0;
const metallurgy = w.metallurgyKnowledge?.[i] || 0;
const tech = clamp(Math.max(farming, metallurgy), 0, 1);
const techColor = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
color = tech > 0.01
? mix(terrainInfo[w.terrain[i]].color, techColor, clamp(0.12 + tech * 0.62, 0.12, 0.74))
: mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
} else if (mode === "pheromone") {
const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v);
} else {
color = terrainInfo[w.terrain[i]].color;
}
const p = i * 4;
data[p] = color[0];
data[p + 1] = color[1];
data[p + 2] = color[2];
data[p + 3] = 255;
}
ctx.putImageData(image, 0, 0);
renderDisasters();
drawTradeLinks();
drawNomadBands();
drawCampaigns();
drawPopulationAndCities(mode);
drawGraphPolityHighlight();
maybeRenderStateGraph();
if (els.frameCost) els.frameCost.textContent = `${Math.round(performance.now() - start)}ミリ秒`;
}
function renderDisasters() {
if (!sim?.disasters?.length) return;
sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires);
if (!sim.disasters.length) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
for (const disaster of sim.disasters) {
const duration = Math.max(1, disaster.expires - disaster.year);
const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1);
const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22);
if (alpha <= 0.005) continue;
const innerRadius = Math.max(1, disaster.radius * 0.32);
const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius);
gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
ctx.fillStyle = gradient;
drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius);
ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
ctx.lineWidth = 1;
strokeCircle(ctx, disaster.x, disaster.y, disaster.radius);
}
ctx.restore();
}
function rgb(color, boost = 0) {
return `rgb(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)})`;
}
function rgba(color, alpha, boost = 0) {
return `rgba(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)}, ${alpha})`;
}
function fillSquare(g, x, y, radius) {
g.fillRect(x - radius, y - radius, radius * 2 + 1, radius * 2 + 1);
}
function strokeLine(g, x1, y1, x2, y2) {
g.beginPath();
g.moveTo(x1, y1);
g.lineTo(x2, y2);
g.stroke();
}
function strokeCircle(g, x, y, r) {
g.beginPath();
g.arc(x, y, r, 0, Math.PI * 2);
g.stroke();
}
function drawTradeLinks() {
const w = sim.world;
ctx.save();
const currentLinkTiles = new Set();
for (const link of sim.tradeLinks) {
for (const tile of link.path || []) currentLinkTiles.add(tile);
}
ctx.globalAlpha = 0.24;
ctx.fillStyle = "#d9b650";
for (const i of sim.activeTradeRouteTiles || []) {
if (currentLinkTiles.has(i)) continue;
ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
}
if (!sim.tradeLinks.length) {
ctx.restore();
return;
}
for (const link of sim.tradeLinks) {
ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42);
ctx.fillStyle = "#ffd75a";
for (const tile of link.path || []) {
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
}
ctx.restore();
}
function drawCampaigns() {
if (!sim?.campaigns?.length) return;
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);
const pulse = 0.5 + Math.sin(age * Math.PI * 8) * 0.5;
const radius = 2.5 + clamp(campaign.progress, 0, 1) * 4 + pulse * 1.5;
if (campaign.type === "border_war" && campaign.affectedTiles?.length) {
ctx.globalAlpha = 0.10 + clamp(campaign.progress, 0, 1) * 0.10;
ctx.fillStyle = "rgba(164, 76, 58, 0.9)";
for (const tile of campaign.affectedTiles) {
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
}
ctx.globalAlpha = 0.55;
ctx.strokeStyle = campaign.type === "border_war" ? "rgba(236, 126, 111, 0.95)" : "rgba(239, 211, 119, 0.95)";
ctx.lineWidth = 1;
strokeCircle(ctx, x + 0.5, y + 0.5, campaign.type === "border_war" ? radius + 2.5 : radius);
if (campaign.type === "border_war" && campaign.targetCityId != null) {
ctx.globalAlpha = 0.82;
ctx.lineWidth = 1.4;
ctx.strokeStyle = "rgba(255, 195, 121, 0.95)";
strokeCircle(ctx, x + 0.5, y + 0.5, radius + 5 + pulse * 2);
}
ctx.globalAlpha = 0.22;
ctx.fillStyle = campaign.type === "nonstate_subjugation" ? "rgba(180, 111, 73, 0.9)" : "rgba(239, 211, 119, 0.9)";
fillSquare(ctx, x, y, 1);
}
ctx.restore();
}
function drawNomadBands() {
if (!sim?.nomadBands?.length) return;
const w = sim.world;
ctx.save();
for (const band of sim.nomadBands) {
const ethnicityColor = sim.ethnicities.get(band.ethnicityId)?.color || [216, 177, 86];
ctx.globalAlpha = 0.07;
ctx.fillStyle = rgba(ethnicityColor, 0.45);
for (const tile of band.influenceTiles || []) {
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
ctx.globalAlpha = 0.34;
ctx.strokeStyle = band.mode === "raiding" || band.mode === "invading"
? "rgba(232, 101, 76, 0.95)"
: band.mode === "settling"
? "rgba(202, 169, 102, 0.9)"
: rgba(ethnicityColor, 0.9, 18);
ctx.lineWidth = 1;
strokeCircle(ctx, band.x + 0.5, band.y + 0.5, Math.max(2.5, band.radius * 0.45));
if (band.mode === "confederating") {
ctx.globalAlpha = 0.26;
strokeCircle(ctx, band.x + 0.5, band.y + 0.5, Math.max(4, band.radius * 0.68));
}
ctx.globalAlpha = 0.22;
ctx.fillStyle = rgba(ethnicityColor, 0.9, 22);
for (const tile of band.trailTiles || []) ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
ctx.globalAlpha = 0.82;
ctx.fillStyle = rgba(ethnicityColor, 0.95, 36);
fillSquare(ctx, Math.round(band.x), Math.round(band.y), 1);
}
ctx.restore();
}
function drawPopulationAndCities(mode) {
ctx.save();
ctx.globalAlpha = 1;
if (mode !== "technology" && mode !== "ethnicity") {
const w = sim.world;
ctx.fillStyle = "rgba(238, 236, 207, 0.16)";
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})`;
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
}
for (const city of sim.cities) {
const radius = cityRenderRadius(city);
const color = cityDisplayColor(city, mode);
ctx.globalAlpha = 1;
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);
}
ctx.restore();
}
function drawGraphPolityHighlight() {
const polityId = graphState.hoverPolityId;
if (polityId === null) return;
const polity = sim.getPolityById(polityId);
if (!polity) return;
const cities = sim.getPolityCities(polity);
if (!cities.length) return;
ctx.save();
ctx.lineWidth = 1;
for (const city of cities) {
const radius = Math.max(3, cityRenderRadius(city) + 2);
const color = polity.color || [238, 232, 188];
ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72;
ctx.strokeStyle = rgb(color, 72);
ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
ctx.globalAlpha = 0.32;
ctx.fillStyle = rgb(color);
ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1));
}
ctx.restore();
}
function cityRenderRadius(city) {
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);
return sim.ethnicities.get(id)?.color || [242, 215, 134];
}
function cityPolity(city) {
return city?.polityId != null ? sim.getPolityById(city.polityId) : null;
}
function cityDisplayColor(city, mode) {
if (mode === "polities") {
const polity = cityPolity(city);
if (polity) return polity.color;
return [218, 205, 154];
}
return cityMajorityColor(city);
}
function showTooltip(event) {
const rect = els.canvas.getBoundingClientRect();
const simRect = els.sim.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
hoverState = {
x,
y,
left: event.clientX - simRect.left + 16,
top: event.clientY - simRect.top + 16,
width: simRect.width,
height: simRect.height
};
renderTooltip();
}
function tooltipRow(label, value, show = true) {
return show ? `<span><b>${label}</b><em>${value}</em></span>` : "";
}
function tooltipSection(title, items) {
const body = items.filter(Boolean).join("");
return body ? `<section><h3>${title}</h3>${body}</section>` : "";
}
function renderTooltip() {
if (!hoverState) return;
const x = hoverState.x;
const y = hoverState.y;
if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) {
hideTooltip();
return;
}
const w = sim.world;
const i = w.idx(x, y);
const city = cityAtDisplayTile(x, y);
if (w.terrain[i] === Terrain.WATER && w.city[i] < 0) {
hideTooltip();
return;
}
const terrain = terrainInfo[w.terrain[i]];
const polity = cityPolity(city);
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;
const height = els.tooltip.offsetHeight;
let left = hoverState.left;
let top = hoverState.top;
if (left + width + margin > hoverState.width) left = hoverState.left - width - 32;
if (top + height + margin > hoverState.height) top = hoverState.height - height - margin;
els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`;
els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`;
}
function cityEthnicityPie(city) {
if (!city?.ethnicityComposition?.size) return "";
const entries = [...city.ethnicityComposition]
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
const total = entries.reduce((sum, [, count]) => sum + count, 0);
if (total <= 0) return "";
const top = entries.slice(0, 5);
const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0);
const slices = other > 0 ? [...top, [0, other]] : top;
let cursor = 0;
const gradient = slices.map(([id, count]) => {
const start = cursor / total * 100;
cursor += count;
const end = cursor / total * 100;
const ethnicity = id ? sim.ethnicities.get(id) : null;
const color = ethnicity?.color || [122, 130, 126];
return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
}).join(", ");
const labels = slices.map(([id, count]) => {
const ethnicity = id ? sim.ethnicities.get(id) : null;
const color = ethnicity?.color || [122, 130, 126];
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 `
<div class="ethnicity-pie-wrap">
<div class="ethnicity-pie" style="background: conic-gradient(${gradient})"></div>
<div class="ethnicity-pie-labels">${labels}</div>
</div>
`;
}
function hideTooltip() {
hoverState = null;
els.tooltip.hidden = true;
}
function activeCampaignsNear(tile) {
if (!sim?.campaigns?.length) return "";
const w = sim.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
const labels = [];
for (const campaign of sim.campaigns) {
if (campaign.status !== "active" || campaign.targetTile == null) continue;
const cx = campaign.targetTile % w.size;
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(`${displayTerm(campaign.type)} ${Math.round(clamp(campaign.progress, 0, 1) * 100)}%`);
}
return labels.slice(0, 2).join("、");
}
function activeNomadNear(tile) {
if (!sim?.nomadBands?.length) return null;
const w = sim.world;
const x = tile % w.size;
const y = Math.floor(tile / w.size);
let best = null;
let bestDistance = Infinity;
for (const band of sim.nomadBands) {
const distance = Math.abs(Math.round(band.x) - x) + Math.abs(Math.round(band.y) - y);
const inInfluence = w.nomadBand?.[tile] === band.id;
if (!inInfluence && distance > Math.max(3, Math.floor(band.radius * 0.55))) continue;
if (distance < bestDistance) {
best = band;
bestDistance = distance;
}
}
return best;
}
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);
if (els.currentDate) els.currentDate.textContent = formatSimDate(sim.year);
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
if (els.ethnicityList) els.ethnicityList.innerHTML = top.map(e => (
`<li><span class="lineage-chip" style="background: rgb(${e.color.join(",")})"></span>` +
`民族${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;
const content = (legendByMode[mode] || legendByMode.resources)();
els.legend.innerHTML = content;
els.legend.hidden = !content;
}
function maybeRenderStateGraph() {
const now = performance.now();
if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return;
lastGraphRenderAt = now;
renderStateGraph();
}
function sampleGraphPower(sample) {
if (Number.isFinite(sample?.power)) return sample.power;
return Math.sqrt(sample?.population || 0) * 1.35 +
Math.sqrt(Math.max(0, sample?.treasury || 0)) * 1.15 +
(sample?.avgLoyalty ?? 0.5) * 16;
}
const graphMetrics = {
power: {
value: sampleGraphPower,
format: value => value.toFixed(1)
},
population: {
value: sample => sample?.population || 0,
format: value => Math.round(value).toLocaleString()
},
cities: {
value: sample => sample?.cities || 0,
format: value => Math.round(value).toLocaleString()
},
loyalty: {
value: sample => sample?.avgLoyalty ?? 0.5,
format: value => `${Math.round(clamp(value, 0, 1) * 100)}%`
}
};
function graphSampleValue(sample, metric) {
return (graphMetrics[metric] || graphMetrics.power).value(sample);
}
function formatGraphValue(value, metric) {
return (graphMetrics[metric] || graphMetrics.power).format(value);
}
function renderStateGraph() {
const canvas = els.stateGraph;
if (!canvas || !sim) return;
const rect = canvas.getBoundingClientRect();
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 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,
visibleFounded: Math.max(history.founded ?? 0, historyWindowStart),
visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart)
}))
.filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart)
.filter(history => {
const isActive = history.active && history.ended === null;
if (graphState.filter === "active" && !isActive) return false;
if (graphState.filter === "ended" && isActive) return false;
if (isActive && !graphState.showActive) return false;
if (!isActive && !graphState.showPast) return false;
return true;
});
const paddingLeft = 34;
const paddingRight = 8;
const paddingTop = 18;
const paddingBottom = 22;
const rowHeight = 24;
const groupHeight = 18;
const scored = histories.map(history => {
const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0, avgLoyalty: 0.5 }];
const peakPower = Math.max(...samples.map(sample => sampleGraphPower(sample)));
const peakPopulation = Math.max(...samples.map(sample => sample.population || 0));
const peakCities = Math.max(...samples.map(sample => sample.cities || 0));
const peakLoyalty = Math.max(...samples.map(sample => sample.avgLoyalty ?? 0.5));
const peakMetric = Math.max(...samples.map(sample => graphSampleValue(sample, graphState.metric)));
const lifespan = (history.ended ?? sim.year) - history.visibleFounded;
const importance = peakPower * 1.6 +
peakCities * 130 +
Math.sqrt(Math.max(0, peakPopulation)) * 6 +
lifespan * 0.35;
return { history, peakPopulation, peakCities, peakLoyalty, peakPower, peakMetric, importance };
});
const compareRows = (a, b) => {
if (graphState.sort === "oldest") {
return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id);
}
if (graphState.sort === "strongest") {
return (b.peakPower - a.peakPower) || ((a.history.founded ?? 0) - (b.history.founded ?? 0));
}
if (graphState.sort === "largest") {
return (b.peakPopulation - a.peakPopulation) || (b.peakCities - a.peakCities);
}
return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id);
};
const selected = scored.sort(compareRows).slice(0, 90).sort(compareRows);
const activeRows = selected.filter(item => item.history.active && item.history.ended === null);
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: `存続中 ${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: `滅亡済み ${pastRows.length}` });
for (const item of pastRows) displayRows.push({ type: "state", item });
}
const logicalHeight = paddingTop +
displayRows.reduce((sum, row) => sum + (row.type === "group" ? groupHeight : rowHeight), 0) +
paddingBottom;
canvas.style.height = `${logicalHeight}px`;
const updatedRect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(updatedRect.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 = updatedRect.width;
const h = logicalHeight;
g.clearRect(0, 0, w, h);
g.fillStyle = "#101315";
g.fillRect(0, 0, w, h);
if (!histories.length || !selected.length || !displayRows.length) {
if (els.historyRange) els.historyRange.textContent = "-";
graphState.rows = [];
graphState.markers = [];
graphState.scale = null;
g.fillStyle = "#7f8984";
g.font = "12px ui-sans-serif, system-ui, sans-serif";
g.fillText("国家の歴史はまだありません", 16, 28);
setEventLegend();
return;
}
let minYear = historyWindowStart;
let maxYear = historyWindowEnd;
for (const item of selected) {
const history = item.history;
minYear = Math.min(minYear, history.visibleFounded);
maxYear = Math.max(maxYear, history.ended ?? sim.year);
}
if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`;
const plotWidth = Math.max(1, w - paddingLeft - paddingRight);
const yearToX = year => paddingLeft + ((clamp(year, minYear, maxYear) - minYear) / Math.max(1, maxYear - minYear)) * plotWidth;
const xToYear = x => minYear + ((clamp(x, paddingLeft, w - paddingRight) - paddingLeft) / plotWidth) * Math.max(1, maxYear - minYear);
const rows = [];
let cursorY = paddingTop;
for (const displayRow of displayRows) {
if (displayRow.type === "group") {
rows.push({ ...displayRow, y: cursorY + groupHeight * 0.5, height: groupHeight });
cursorY += groupHeight;
continue;
}
const item = displayRow.item;
rows.push({
...item,
type: "state",
y: cursorY + rowHeight * 0.5,
height: rowHeight,
color: item.history.color || hslToRgb((item.history.id * 0.38196601125) % 1, 0.58, 0.62),
samples: (item.history.visibleSamples || [])
.filter(sample => Number.isFinite(sample.year))
.sort((a, b) => a.year - b.year)
});
cursorY += rowHeight;
}
let minMetric = Infinity;
let maxMetric = -Infinity;
let minCities = Infinity;
let maxCities = -Infinity;
for (const row of rows.filter(row => row.type === "state")) {
for (const sample of row.samples) {
const value = graphSampleValue(sample, graphState.metric);
minMetric = Math.min(minMetric, value);
maxMetric = Math.max(maxMetric, value);
minCities = Math.min(minCities, sample.cities || 0);
maxCities = Math.max(maxCities, sample.cities || 0);
}
}
if (!Number.isFinite(minMetric) || !Number.isFinite(maxMetric)) {
minMetric = 0;
maxMetric = 1;
}
if (!Number.isFinite(minCities) || !Number.isFinite(maxCities)) {
minCities = 0;
maxCities = 1;
}
graphState.rows = rows.filter(row => row.type === "state");
graphState.markers = [];
graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear };
const axisY = h - paddingBottom + 4;
g.strokeStyle = "rgba(168, 177, 170, 0.22)";
g.lineWidth = 1;
strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY);
g.fillStyle = "#8c9690";
g.font = "10px ui-sans-serif, system-ui, sans-serif";
g.textAlign = "left";
g.fillText(formatGraphYear(minYear), paddingLeft, h - 5);
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 = graphState.showEventIcons ? (sim.graphEvents || [])
.filter(event => event.importance >= 2)
.filter(event => event.year >= minYear && event.year <= maxYear)
.filter(event => event.type !== "war" || event.data?.outcome === "started")
.filter(event => graphEventTypes[event.type]) : [];
const topCollisions = new Map();
for (const event of topEvents) {
const rawX = yearToX(event.year);
const bucket = Math.round(rawX / 18);
const count = topCollisions.get(bucket) || 0;
topCollisions.set(bucket, count + 1);
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;
graphEvent.draw(g, x, y, r);
graphState.markers.push({ row: null, event, x, y, radius: r + 3 });
}
rows.forEach(row => {
const alpha = row.type === "group" ? 0.16 : 0.12;
if (row.type === "group") {
g.fillStyle = "#7f8984";
g.font = "10px ui-sans-serif, system-ui, sans-serif";
g.textAlign = "left";
g.fillText(row.label, 4, row.y + 3);
}
g.strokeStyle = `rgba(168, 177, 170, ${alpha})`;
g.lineWidth = 1;
strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y);
});
rows.forEach(row => {
if (row.type !== "state") return;
const history = row.history;
g.textAlign = "left";
g.fillStyle = "#a8b1aa";
g.font = "9px ui-sans-serif, system-ui, sans-serif";
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;
const normalizedMetric = (metricValue - minMetric) / Math.max(1, maxMetric - minMetric);
const metricT = clamp(normalizedMetric, 0, 1);
const cityT = clamp((cities - minCities) / Math.max(1, maxCities - minCities), 0, 1);
const lowColor = mix([56, 62, 60], row.color, 0.34);
const highColor = mix(row.color, [244, 246, 238], 0.18);
const segmentColor = mix(lowColor, highColor, metricT);
let alpha = (0.22 + metricT * 0.76) * inactiveMultiplier * alphaScale;
alpha = clamp(alpha, 0.10, 0.96);
const thickness = clamp(3.5 + cityT * 5.5 + metricT * 4.5, 3.5, 13);
g.lineWidth = thickness;
g.lineCap = "round";
g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${clamp(alpha * 0.28, 0.08, 0.28)})`;
strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`;
g.lineWidth = Math.max(2, thickness * 0.62);
strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
g.lineCap = "butt";
};
if (row.samples.length > 1) {
if (history.visibleFounded < row.samples[0].year) {
strokeSegment(history.visibleFounded, row.samples[0].year, graphSampleValue(row.samples[0], graphState.metric), row.samples[0].cities, 0.42);
}
for (let i = 0; i < row.samples.length - 1; i++) {
const sampleA = row.samples[i];
const sampleB = row.samples[i + 1];
strokeSegment(
sampleA.year,
sampleB.year,
(graphSampleValue(sampleA, graphState.metric) + graphSampleValue(sampleB, graphState.metric)) * 0.5,
((sampleA.cities || 0) + (sampleB.cities || 0)) * 0.5
);
}
const lastSample = row.samples[row.samples.length - 1];
const endYear = history.ended ?? sim.year;
if (lastSample.year < endYear) {
strokeSegment(lastSample.year, endYear, graphSampleValue(lastSample, graphState.metric), lastSample.cities, 0.55);
}
} else {
const sample = row.samples[0] || { population: 0, cities: row.peakCities, avgLoyalty: 0.5 };
strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValue(sample, graphState.metric), sample.cities || row.peakCities);
}
});
rows.forEach(row => {
if (row.type !== "state") return;
const events = graphState.showEventIcons ? (row.history.events || [])
.filter(event => event.importance >= 2)
.filter(event => event.year >= minYear && event.year <= maxYear)
.filter(event => event.type !== "war" || event.data?.outcome === "started")
.filter(event => graphEventTypes[event.type]) : [];
const collisions = new Map();
for (const event of events) {
const rawX = yearToX(event.year);
const bucket = Math.round(rawX / 18);
const count = collisions.get(bucket) || 0;
collisions.set(bucket, count + 1);
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;
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();
g.arc(x, y, r, 0, Math.PI * 2);
g.fill();
}
function drawGraphPolygon(g, points) {
g.beginPath();
g.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) g.lineTo(points[i][0], points[i][1]);
g.closePath();
g.fill();
}
function drawGraphDiamond(g, x, y, r) {
drawGraphPolygon(g, [[x, y - r], [x + r, y], [x, y + r], [x - r, y]]);
}
function drawGraphTriangleUp(g, x, y, r) {
drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]);
}
function drawGraphTriangleDown(g, x, y, r) {
drawGraphPolygon(g, [[x, y + r], [x + r, y - r], [x - r, y - r]]);
}
const graphEventTypes = {
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 || "出来事";
}
function graphEventSummary(event) {
return graphEventTypes[event.type]?.summary(event.data || {}) || "";
}
function showStateGraphMarkerTooltip(marker, pointerX, pointerY) {
if (!els.stateGraphTooltip) return;
const title = marker.row
? `国家${marker.row.history.id} ${graphEventLabel(marker.event)}`
: graphEventLabel(marker.event);
els.stateGraphTooltip.innerHTML = `
<strong>${title}</strong>
<span>${formatGraphYear(marker.event.year)}</span>
<span>${graphEventSummary(marker.event)}</span>
`;
els.stateGraphTooltip.hidden = false;
const scroll = els.stateGraph.parentElement;
const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8);
const left = clamp(pointerX + 10, 8, maxLeft);
const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10);
els.stateGraphTooltip.style.left = `${left}px`;
els.stateGraphTooltip.style.top = `${top}px`;
}
function hideStateGraphMarkerTooltip() {
if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true;
}
function updateStateGraphInfo(event) {
if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return;
const y = event.offsetY;
const x = event.offsetX;
const marker = (graphState.markers || [])
.map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) }))
.filter(item => item.distance <= item.candidate.radius);
const nearestMarker = bestBy(marker, item => -item.distance)?.candidate;
if (nearestMarker) {
if (!nearestMarker.row) {
els.stateGraphInfo.textContent = `${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
showStateGraphMarkerTooltip(nearestMarker, x, y);
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
}
return;
}
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;
if (!running) render();
}
return;
}
const row = graphState.rows
.map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) }))
.filter(item => item.distance <= item.candidate.height * 0.5);
const nearestRow = bestBy(row, item => -item.distance)?.candidate;
if (!nearestRow) {
els.stateGraphInfo.textContent = "";
hideStateGraphMarkerTooltip();
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
}
return;
}
if (graphState.hoverPolityId !== nearestRow.history.id) {
graphState.hoverPolityId = nearestRow.history.id;
if (!running) render();
}
hideStateGraphMarkerTooltip();
const year = graphState.scale.xToYear(x);
const samples = nearestRow.samples.length ? nearestRow.samples : [{
year,
population: 0,
cities: nearestRow.peakCities,
avgLoyalty: 0.5,
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 ? "存続中" : "滅亡済み";
els.stateGraphInfo.textContent =
`国家${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 = "";
hideStateGraphMarkerTooltip();
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
}
}
function formatGraphYear(week) {
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()}年 ${monthOfYear}月 第${weekOfMonth}週`;
}