2026-09-16 20:53:17 +09:00
|
|
|
|
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] || "状況変化"; }
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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;
|
2026-05-17 21:08:03 +09:00
|
|
|
|
const polityColors = new Map();
|
2026-05-14 20:15:23 +09:00
|
|
|
|
if (mode === "polities") {
|
2026-05-17 21:08:03 +09:00
|
|
|
|
for (const polity of sim.polities) {
|
|
|
|
|
|
polityColors.set(polity.id, {
|
|
|
|
|
|
color: polity.color,
|
|
|
|
|
|
isGraphHover: graphState.hoverPolityId === polity.id
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-05-18 02:51:53 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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") {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const pressure = w.populationPressure[i] || 0;
|
2026-05-17 21:08:03 +09:00
|
|
|
|
const v = clamp(pressure, 0, 1.6) / 1.6;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
|
|
|
|
|
|
} else if (mode === "polities") {
|
|
|
|
|
|
color = terrainInfo[w.terrain[i]].color;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const territory = w.territoryOwner[i];
|
2026-05-17 21:08:03 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
|
} else if (mode === "technology") {
|
2026-09-16 13:51:43 +09:00
|
|
|
|
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);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
} 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();
|
2026-09-16 13:51:43 +09:00
|
|
|
|
drawNomadBands();
|
2026-05-17 21:08:03 +09:00
|
|
|
|
drawCampaigns();
|
2026-09-16 13:51:43 +09:00
|
|
|
|
drawPopulationAndCities(mode);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
drawGraphPolityHighlight();
|
|
|
|
|
|
maybeRenderStateGraph();
|
2026-09-16 20:53:17 +09:00
|
|
|
|
if (els.frameCost) els.frameCost.textContent = `${Math.round(performance.now() - start)}ミリ秒`;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
|
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;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
if (campaign.type !== "border_war") continue;
|
2026-05-17 21:08:03 +09:00
|
|
|
|
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;
|
2026-05-18 02:51:53 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
|
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;
|
2026-05-18 02:51:53 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-09-16 13:51:43 +09:00
|
|
|
|
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) {
|
2026-05-14 20:15:23 +09:00
|
|
|
|
ctx.save();
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
if (mode !== "technology" && mode !== "ethnicity") {
|
2026-09-16 13:51:43 +09:00
|
|
|
|
const w = sim.world;
|
|
|
|
|
|
ctx.fillStyle = "rgba(238, 236, 207, 0.16)";
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const populatedTiles = sim.activePopulationTiles || [];
|
|
|
|
|
|
for (const tile of populatedTiles) {
|
|
|
|
|
|
const pop = (w.population?.[tile] || 0) / (SimConfig.population.populationScale || 1);
|
2026-09-16 13:51:43 +09:00
|
|
|
|
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);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const city of sim.cities) {
|
|
|
|
|
|
const radius = cityRenderRadius(city);
|
|
|
|
|
|
const color = cityDisplayColor(city, mode);
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const city = cityAtDisplayTile(x, y);
|
|
|
|
|
|
if (w.terrain[i] === Terrain.WATER && w.city[i] < 0) {
|
|
|
|
|
|
hideTooltip();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
|
const terrain = terrainInfo[w.terrain[i]];
|
|
|
|
|
|
const polity = cityPolity(city);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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}`;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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) {
|
2026-05-17 21:08:03 +09:00
|
|
|
|
if (!city?.ethnicityComposition?.size) return "";
|
|
|
|
|
|
const entries = [...city.ethnicityComposition]
|
|
|
|
|
|
.filter(([, count]) => count > 0)
|
2026-05-14 20:15:23 +09:00
|
|
|
|
.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;
|
2026-05-17 21:08:03 +09:00
|
|
|
|
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
|
|
|
|
|
const color = ethnicity?.color || [122, 130, 126];
|
2026-05-14 20:15:23 +09:00
|
|
|
|
return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
|
|
|
|
|
|
}).join(", ");
|
|
|
|
|
|
const labels = slices.map(([id, count]) => {
|
2026-05-17 21:08:03 +09:00
|
|
|
|
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
|
|
|
|
|
const color = ethnicity?.color || [122, 130, 126];
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const label = id ? `民族${id}` : "その他";
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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">
|
2026-05-17 21:08:03 +09:00
|
|
|
|
<div class="ethnicity-pie" style="background: conic-gradient(${gradient})"></div>
|
2026-05-14 20:15:23 +09:00
|
|
|
|
<div class="ethnicity-pie-labels">${labels}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function hideTooltip() {
|
|
|
|
|
|
hoverState = null;
|
|
|
|
|
|
els.tooltip.hidden = true;
|
|
|
|
|
|
}
|
2026-05-17 21:08:03 +09:00
|
|
|
|
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);
|
2026-05-18 02:51:53 +09:00
|
|
|
|
const nearTarget = Math.abs(cx - x) + Math.abs(cy - y) <= 3;
|
|
|
|
|
|
const nearFront = campaign.affectedTiles?.includes(tile);
|
|
|
|
|
|
if (!nearTarget && !nearFront) continue;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
labels.push(`${displayTerm(campaign.type)} ${Math.round(clamp(campaign.progress, 0, 1) * 100)}%`);
|
2026-05-17 21:08:03 +09:00
|
|
|
|
}
|
2026-09-16 20:53:17 +09:00
|
|
|
|
return labels.slice(0, 2).join("、");
|
2026-05-17 21:08:03 +09:00
|
|
|
|
}
|
2026-09-16 13:51:43 +09:00
|
|
|
|
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;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
if (els.currentDate) els.currentDate.textContent = formatSimDate(sim.year);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
if (els.ethnicityList) els.ethnicityList.innerHTML = top.map(e => (
|
2026-05-14 20:15:23 +09:00
|
|
|
|
`<li><span class="lineage-chip" style="background: rgb(${e.color.join(",")})"></span>` +
|
2026-09-16 20:53:17 +09:00
|
|
|
|
`民族${e.id} 人口 ${Math.round(e.population).toLocaleString()}<br>多様性 ${Math.round(e.diversity * 100)}% 親系統 ${e.parent ? `民族${e.parent}` : "なし"}</li>`
|
2026-05-14 20:15:23 +09:00
|
|
|
|
)).join("");
|
|
|
|
|
|
maybeRenderStateGraph();
|
|
|
|
|
|
}
|
|
|
|
|
|
function setLegend() {
|
|
|
|
|
|
const mode = els.viewMode.value;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const content = (legendByMode[mode] || legendByMode.resources)();
|
|
|
|
|
|
els.legend.innerHTML = content;
|
|
|
|
|
|
els.legend.hidden = !content;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
renderWorldTrendGraph(historyWindowStart, historyWindowEnd);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
graphState.filter = els.historyFilter?.value || graphState.filter;
|
|
|
|
|
|
graphState.sort = els.historySort?.value || graphState.sort;
|
|
|
|
|
|
graphState.metric = els.historyMetric?.value || graphState.metric;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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))
|
2026-05-14 20:15:23 +09:00
|
|
|
|
.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) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
displayRows.push({ type: "group", label: `存続中 ${activeRows.length}` });
|
2026-05-14 20:15:23 +09:00
|
|
|
|
for (const item of activeRows) displayRows.push({ type: "state", item });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (graphState.showPast && graphState.filter !== "active" && pastRows.length) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
displayRows.push({ type: "group", label: `滅亡済み ${pastRows.length}` });
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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";
|
2026-09-16 20:53:17 +09:00
|
|
|
|
g.fillText("国家の歴史はまだありません", 16, 28);
|
|
|
|
|
|
setEventLegend();
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const topEvents = graphState.showEventIcons ? (sim.graphEvents || [])
|
2026-05-14 20:15:23 +09:00
|
|
|
|
.filter(event => event.importance >= 2)
|
|
|
|
|
|
.filter(event => event.year >= minYear && event.year <= maxYear)
|
2026-09-16 20:53:17 +09:00
|
|
|
|
.filter(event => event.type !== "war" || event.data?.outcome === "started")
|
|
|
|
|
|
.filter(event => graphEventTypes[event.type]) : [];
|
2026-05-14 20:15:23 +09:00
|
|
|
|
const topCollisions = new Map();
|
|
|
|
|
|
for (const event of topEvents) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const rawX = yearToX(event.year);
|
|
|
|
|
|
const bucket = Math.round(rawX / 18);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
const count = topCollisions.get(bucket) || 0;
|
|
|
|
|
|
topCollisions.set(bucket, count + 1);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const point = spreadGraphMarker(rawX, paddingTop * 0.5, count, paddingLeft, w - paddingRight);
|
|
|
|
|
|
const x = point.x;
|
|
|
|
|
|
const y = point.y;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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";
|
2026-09-16 20:53:17 +09:00
|
|
|
|
g.fillText(`国${history.id}`, 4, row.y + 3);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const events = graphState.showEventIcons ? (row.history.events || [])
|
2026-05-14 20:15:23 +09:00
|
|
|
|
.filter(event => event.importance >= 2)
|
|
|
|
|
|
.filter(event => event.year >= minYear && event.year <= maxYear)
|
2026-09-16 20:53:17 +09:00
|
|
|
|
.filter(event => event.type !== "war" || event.data?.outcome === "started")
|
|
|
|
|
|
.filter(event => graphEventTypes[event.type]) : [];
|
2026-05-14 20:15:23 +09:00
|
|
|
|
const collisions = new Map();
|
|
|
|
|
|
for (const event of events) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const rawX = yearToX(event.year);
|
|
|
|
|
|
const bucket = Math.round(rawX / 18);
|
2026-05-14 20:15:23 +09:00
|
|
|
|
const count = collisions.get(bucket) || 0;
|
|
|
|
|
|
collisions.set(bucket, count + 1);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const point = spreadGraphMarker(rawX, row.y, count, paddingLeft, w - paddingRight);
|
|
|
|
|
|
const x = point.x;
|
|
|
|
|
|
const y = point.y;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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 };
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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 = {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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) }
|
2026-05-14 20:15:23 +09:00
|
|
|
|
};
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
|
function graphEventLabel(event) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
return graphEventTypes[event.type]?.label || "出来事";
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
function graphEventSummary(event) {
|
|
|
|
|
|
return graphEventTypes[event.type]?.summary(event.data || {}) || "";
|
|
|
|
|
|
}
|
|
|
|
|
|
function showStateGraphMarkerTooltip(marker, pointerX, pointerY) {
|
|
|
|
|
|
if (!els.stateGraphTooltip) return;
|
|
|
|
|
|
const title = marker.row
|
2026-09-16 20:53:17 +09:00
|
|
|
|
? `国家${marker.row.history.id} ${graphEventLabel(marker.event)}`
|
2026-05-14 20:15:23 +09:00
|
|
|
|
: 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;
|
|
|
|
|
|
}
|
2026-09-16 20:53:17 +09:00
|
|
|
|
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)}`;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
els.stateGraphInfo.textContent = "";
|
2026-05-14 20:15:23 +09:00
|
|
|
|
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);
|
2026-09-16 20:53:17 +09:00
|
|
|
|
const status = nearestRow.history.active && nearestRow.history.ended === null ? "存続中" : "滅亡済み";
|
2026-05-14 20:15:23 +09:00
|
|
|
|
els.stateGraphInfo.textContent =
|
2026-09-16 20:53:17 +09:00
|
|
|
|
`国家${nearestRow.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` +
|
|
|
|
|
|
`${({power: "国力", population: "人口", cities: "都市数", loyalty: "忠誠度"})[graphState.metric]} ${formatGraphValue(metricValue, graphState.metric)}`;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
function clearStateGraphInfo() {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
if (els.stateGraphInfo) els.stateGraphInfo.textContent = "";
|
2026-05-14 20:15:23 +09:00
|
|
|
|
hideStateGraphMarkerTooltip();
|
|
|
|
|
|
if (graphState.hoverPolityId !== null) {
|
|
|
|
|
|
graphState.hoverPolityId = null;
|
|
|
|
|
|
if (!running) render();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function formatGraphYear(week) {
|
2026-09-16 20:53:17 +09:00
|
|
|
|
return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}年`;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-09-16 20:53:17 +09:00
|
|
|
|
return `${year.toLocaleString()}年 ${monthOfYear}月 第${weekOfMonth}週`;
|
2026-05-14 20:15:23 +09:00
|
|
|
|
}
|