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 ? `${label}${value}` : ""; } function tooltipSection(title, items) { const body = items.filter(Boolean).join(""); return body ? `

${title}

${body}
` : ""; } 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 = `

都市データ

${cityRows.filter(Boolean).join("")}${cityEthnicityPie(city)}
`; } 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 = `${city ? `都市${city.id}` : terrain.name}${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 `${label} ${Math.round(count / total * 100)}%`; }).join(""); return `
${labels}
`; } 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 => ( `
  • ` + `民族${e.id} 人口 ${Math.round(e.population).toLocaleString()}
    多様性 ${Math.round(e.diversity * 100)}% 親系統 ${e.parent ? `民族${e.parent}` : "なし"}
  • ` )).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 = '

    出来事アイコンは非表示です。

    '; 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 = '

    現在、歴史グラフに出来事の記号は表示されていません。

    '; return; } els.eventLegend.innerHTML = types.map(type => `${graphEventTypes[type].label}` ).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 = ` ${title} ${formatGraphYear(marker.event.year)} ${graphEventSummary(marker.event)} `; 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}週`; }