diff --git a/index.html b/index.html index ff778b3..96574af 100644 --- a/index.html +++ b/index.html @@ -120,6 +120,7 @@
+
Hover a row for details
diff --git a/script.js b/script.js index 2dad4bc..f502b26 100644 --- a/script.js +++ b/script.js @@ -63,6 +63,20 @@ const SimConfig = Object.freeze({ cityWeight: 0.035, routeWeight: 1.35, minimumInfluence: 0.18 + }), + disaster: Object.freeze({ + checkIntervalYears: 8, + baseChance: 0.38, + minRadius: 6, + maxRadius: 34, + rareLargeChance: 0.10, + largeRadiusMin: 28, + largeRadiusMax: 54, + minIntensity: 0.25, + maxIntensity: 0.95, + visualDurationYears: 18, + maxActiveVisuals: 12, + maxHistory: 160 }) }); @@ -105,7 +119,8 @@ const els = { historyMetric: document.getElementById("historyMetric"), toggleActiveStates: document.getElementById("toggleActiveStates"), togglePastStates: document.getElementById("togglePastStates"), - stateGraphInfo: document.getElementById("stateGraphInfo") + stateGraphInfo: document.getElementById("stateGraphInfo"), + stateGraphTooltip: document.getElementById("stateGraphTooltip") }; const ctx = els.canvas.getContext("2d", { alpha: false }); @@ -125,6 +140,7 @@ const graphState = { showPast: true, hoverPolityId: null, rows: [], + markers: [], scale: null }; @@ -455,6 +471,8 @@ class Simulation { this.polities = []; this.polityById = new Map(); this.wars = []; + this.disasters = []; + this.disasterHistory = []; this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; @@ -465,6 +483,7 @@ class Simulation { this.nextCity = 1; this.nextPolity = 1; this.nextWar = 1; + this.nextDisaster = 1; this.year = 0; this.deaths = 0; this.maxAgents = Math.max( @@ -645,6 +664,7 @@ class Simulation { for (const child of acceptedOffspring) this.addAgentToOccupancy(child); if (this.year % 2 === 0) this.updateWorldFields(2); + this.maybeSpawnDisaster(); if (this.year % years(1) === 0) { this.updateCities(); if (this.year % years(2) === 0) this.updateRegionalCultures(); @@ -1774,10 +1794,19 @@ class Simulation { active: true, centerCityId: polity.centerCityId ?? null, fate: null, - samples: [] + samples: [], + events: [], + peakPower: 0, + peakYear: null, + peakEventYear: null }); } - return this.polityHistory.get(polity.id); + const history = this.polityHistory.get(polity.id); + history.events ??= []; + history.peakPower ??= 0; + history.peakYear ??= null; + history.peakEventYear ??= null; + return history; } averagePolityLoyalty(polity) { @@ -1798,14 +1827,28 @@ class Simulation { Math.sqrt(Math.max(0, treasury)) * 1.15 + avgLoyalty * 16; history.centerCityId = polity.centerCityId ?? history.centerCityId; - history.samples.push({ + const sample = { year: this.year, cities: cities.length, population, treasury, avgLoyalty, power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower - }); + }; + history.samples.push(sample); + if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) { + history.peakPower = sample.power; + history.peakYear = this.year; + const oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40); + if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) { + this.addPolityEvent(polity.id, "peak", this.year, { + power: sample.power, + cities: sample.cities, + population: sample.population + }, 2); + history.peakEventYear = this.year; + } + } const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; while (history.samples.length > maxSamples) history.samples.shift(); } @@ -1814,6 +1857,24 @@ class Simulation { for (const polity of this.polities) this.samplePolityHistory(polity); } + addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) { + let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null; + if (!history) { + const polity = this.getPolityById(polityId); + if (polity) history = this.ensurePolityHistory(polity); + } + if (!history) return; + history.events ??= []; + const sameYearDuplicate = history.events.some(event => + event.type === type && + event.year === year && + JSON.stringify(event.data || {}) === JSON.stringify(data || {}) + ); + if (sameYearDuplicate) return; + history.events.push({ year, type, importance, data }); + while (history.events.length > 120) history.events.shift(); + } + selectNewLeader(polity, forced = false) { if (!polity) return; const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5); @@ -1853,6 +1914,161 @@ class Simulation { } } + maybeSpawnDisaster() { + const config = SimConfig.disaster; + const interval = years(config?.checkIntervalYears ?? 8); + if (!interval || this.year % interval !== 0) return; + if (this.rng.next() > (config?.baseChance ?? 0.38)) return; + this.spawnDisaster(); + } + + spawnDisaster() { + const config = SimConfig.disaster; + const large = this.rng.next() < (config?.rareLargeChance ?? 0.10); + const radius = large + ? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54) + : this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34); + const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95); + this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity); + } + + applyDisaster(x, y, radius, intensity) { + const config = SimConfig.disaster; + const w = this.world; + const affectedPolityMap = new Map(); + const affectedPolityIds = new Set(); + for (const city of this.cities) { + if (city.polityId === null) continue; + if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId); + } + const polityPopulationBefore = new Map(); + for (const id of affectedPolityIds) { + const polity = this.getPolityById(id); + if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity)); + } + + let affectedAgents = 0; + for (const agent of this.agents) { + if (!agent.alive) continue; + const d = Math.hypot(agent.x - x, agent.y - y); + if (d > radius) continue; + const falloff = 1 - d / radius; + const damage = clamp(intensity * falloff * falloff, 0, 1); + if (this.rng.next() < damage * 0.32) { + if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent); + agent.alive = false; + this.deaths++; + affectedAgents++; + } else { + agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45)); + if (agent.tech) { + agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035); + agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04); + } + } + } + + let affectedCities = 0; + let totalPopulationLoss = 0; + for (const city of this.cities) { + const d = Math.hypot(city.x - x, city.y - y); + if (d > radius) continue; + const falloff = 1 - d / radius; + const damage = clamp(intensity * falloff * falloff, 0, 1); + if (damage <= 0) continue; + affectedCities++; + const popBefore = city.population || 0; + const lossRate = clamp(damage * 0.25, 0, 0.45); + const loss = Math.floor(popBefore * lossRate); + city.population = Math.max(0, popBefore - loss); + if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss); + city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55)); + city.strength = Math.max(0, city.strength - damage * 0.38); + this.deaths += Math.floor(loss * 0.35); + totalPopulationLoss += loss; + + if (city.polityId !== null) { + let entry = affectedPolityMap.get(city.polityId); + if (!entry) { + entry = { + polityId: city.polityId, + populationBefore: polityPopulationBefore.get(city.polityId) || 0, + populationLoss: 0, + cityIds: new Set() + }; + affectedPolityMap.set(city.polityId, entry); + } + entry.populationLoss += loss; + entry.cityIds.add(city.id); + const polity = this.getPolityById(city.polityId); + if (polity) { + polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5); + polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1); + polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1); + } + } + } + + const minX = Math.max(0, Math.floor(x - radius)); + const maxX = Math.min(w.size - 1, Math.ceil(x + radius)); + const minY = Math.max(0, Math.floor(y - radius)); + const maxY = Math.min(w.size - 1, Math.ceil(y + radius)); + for (let ty = minY; ty <= maxY; ty++) { + for (let tx = minX; tx <= maxX; tx++) { + const d = Math.hypot(tx - x, ty - y); + if (d > radius) continue; + const falloff = 1 - d / radius; + const damage = clamp(intensity * falloff * falloff, 0, 1); + const tile = w.idx(tx, ty); + w.resource[tile] *= (1 - damage * 0.45); + w.farmland[tile] *= (1 - damage * 0.35); + w.pheromone[tile] *= (1 - damage * 0.25); + } + } + + const affectedPolities = [...affectedPolityMap.values()].map(entry => { + const populationBefore = Math.max(1, entry.populationBefore || 0); + const lossRate = entry.populationLoss / populationBefore; + return { + polityId: entry.polityId, + populationBefore: entry.populationBefore, + populationLoss: entry.populationLoss, + lossRate, + cityIds: [...entry.cityIds] + }; + }); + const id = this.nextDisaster++; + const record = { + id, + year: this.year, + x, + y, + radius, + intensity, + expires: this.year + years(config?.visualDurationYears ?? 18), + affectedAgents, + affectedCities, + totalPopulationLoss, + affectedPolities + }; + this.disasters.push(record); + this.disasterHistory.push(record); + for (const entry of affectedPolities) { + if (entry.lossRate >= 0.10) { + this.addPolityEvent(entry.polityId, "disaster", this.year, { + disasterId: id, + populationLoss: entry.populationLoss, + lossRate: entry.lossRate, + affectedCities: entry.cityIds.length + }, entry.lossRate >= 0.20 ? 3 : 2); + } + } + this.disasters = this.disasters + .filter(disaster => this.year <= disaster.expires) + .slice(-(config?.maxActiveVisuals ?? 12)); + while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift(); + } + markPolityEnded(polity, reason = "dissolved") { if (!polity) return; const history = this.ensurePolityHistory(polity); @@ -2641,6 +2857,34 @@ class Simulation { } } + detectPolityFamines() { + const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06; + for (const polity of this.polities) { + const cities = this.getPolityCities(polity); + if (!cities.length) continue; + let poorCities = 0; + let totalFood = 0; + let totalPopulation = 0; + for (const city of cities) { + const population = Math.max(1, city.population || 0); + const perCapita = (city.storedResources || 0) / population; + if (perCapita < minimumFood) poorCities++; + totalFood += city.storedResources || 0; + totalPopulation += population; + } + const poorRate = poorCities / cities.length; + const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation); + const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035; + if (!famine) continue; + if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue; + this.addPolityEvent(polity.id, "famine", this.year, { + poorRate, + avgPerCapitaFood + }, poorRate > 0.65 ? 3 : 2); + polity.lastFamineYear = this.year; + } + } + cleanupPolities() { const survivors = []; for (const polity of this.polities) { @@ -2650,10 +2894,17 @@ class Simulation { continue; } let center = this.getCityById(polity.centerCityId); - if (!center) { + if (!center || center.population <= 0) { + const oldCenterCityId = polity.centerCityId; center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); polity.centerCityId = center.id; center.loyalty = 1; + if (oldCenterCityId !== center.id) { + this.addPolityEvent(polity.id, "capitalShift", this.year, { + oldCenterCityId, + newCenterCityId: center.id + }, 2); + } polity.treasury *= 0.5; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1); @@ -2695,6 +2946,7 @@ class Simulation { if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); this.collectAndRedistributeResources(); + this.detectPolityFamines(); if (this.erodePolityLegitimacy) this.erodePolityLegitimacy(); if (this.triggerPolityCrises) this.triggerPolityCrises(); @@ -3060,6 +3312,7 @@ class Simulation { nextCity: this.nextCity, nextPolity: this.nextPolity, nextWar: this.nextWar, + nextDisaster: this.nextDisaster, maxAgents: this.maxAgents, world: { size: this.world.size, @@ -3107,9 +3360,12 @@ class Simulation { leaderStarted: p.leaderStarted, leaderTenureYears: p.leaderTenureYears, crisis: p.crisis, - lastCrisisYear: p.lastCrisisYear + lastCrisisYear: p.lastCrisisYear, + lastFamineYear: p.lastFamineYear ?? null })), wars: this.wars, + disasters: this.disasters, + disasterHistory: this.disasterHistory, polityHistory: [...this.polityHistory.values()], deadPolityHistories: this.deadPolityHistories }; @@ -3125,6 +3381,7 @@ class Simulation { sim.nextCity = state.nextCity || 1; sim.nextPolity = state.nextPolity || 1; sim.nextWar = state.nextWar || 1; + sim.nextDisaster = state.nextDisaster || 1; sim.maxAgents = state.maxAgents || 30000; sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); @@ -3196,7 +3453,8 @@ class Simulation { leaderStarted: p.leaderStarted ?? p.founded ?? sim.year, leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96), crisis: p.crisis ?? 0, - lastCrisisYear: p.lastCrisisYear ?? sim.year + lastCrisisYear: p.lastCrisisYear ?? sim.year, + lastFamineYear: p.lastFamineYear ?? null })); sim.rebuildIndexes(); sim.wars = (state.wars || []).map(w => ({ @@ -3212,6 +3470,23 @@ class Simulation { })).filter(w => w.ended === null); const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0); sim.nextWar = Math.max(sim.nextWar, maxWarId + 1); + const restoreDisaster = d => ({ + id: d.id, + year: d.year || 0, + x: d.x || 0, + y: d.y || 0, + radius: d.radius || 8, + intensity: d.intensity || 0.5, + expires: d.expires ?? ((d.year || 0) + years(18)), + affectedAgents: d.affectedAgents || 0, + affectedCities: d.affectedCities || 0, + totalPopulationLoss: d.totalPopulationLoss || 0, + affectedPolities: d.affectedPolities || [] + }); + sim.disasters = (state.disasters || []).map(restoreDisaster); + sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster); + const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0); + sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1); sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, { id: h.id, color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), @@ -3220,7 +3495,11 @@ class Simulation { active: h.active !== false, centerCityId: h.centerCityId ?? null, fate: h.fate || null, - samples: h.samples || [] + samples: h.samples || [], + events: h.events || [], + peakPower: h.peakPower || 0, + peakYear: h.peakYear ?? null, + peakEventYear: h.peakEventYear ?? null }])); sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({ id: h.id, @@ -3230,7 +3509,11 @@ class Simulation { active: false, centerCityId: h.centerCityId ?? null, fate: h.fate || null, - samples: h.samples || [] + samples: h.samples || [], + events: h.events || [], + peakPower: h.peakPower || 0, + peakYear: h.peakYear ?? null, + peakEventYear: h.peakEventYear ?? null })); for (const polity of sim.polities) sim.ensurePolityHistory(polity); sim.tradeLinks = []; @@ -3304,6 +3587,7 @@ function render() { } ctx.putImageData(image, 0, 0); + renderDisasters(); drawTradeLinks(); drawAgentsAndCities(mode); drawGraphPolityHighlight(); @@ -3311,6 +3595,35 @@ function render() { els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } +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; + ctx.beginPath(); + ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); +} + function drawTradeLinks() { const w = sim.world; ctx.save(); @@ -3464,44 +3777,53 @@ function renderTooltip() { const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0; const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null; const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; + const rows = items => items.filter(Boolean).join(""); + const row = (label, value) => `${label}${value}`; + const section = (title, items) => { + const body = rows(items); + return body ? `

${title}

${body}
` : ""; + }; els.tooltip.innerHTML = ` - ${agent ? "Agent group" : terrain.name} - Tile${x}, ${y} - Terrain${terrain.name} - Resources${w.resource[i].toFixed(1)} - Fertility${w.fertility[i].toFixed(2)} - Temp / humid${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)} - Water influence${waterInfluence} - Minerals${w.mineral[i].toFixed(2)} - Pheromone${w.pheromone[i].toFixed(1)} - Pressure${w.pressure[i].toFixed(0)} - Local culture${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)} - ${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""} - ${city ? `City#${city.id}` : ""} - ${city ? `Urban pop${city.population.toLocaleString()}` : ""} - ${city ? `Food stock${city.storedResources.toFixed(1)}` : ""} - ${city ? `Supply stress${(city.supplyStress || 0).toFixed(2)}` : ""} - ${city ? `Farmland radius${city.agriculturalRadius}` : ""} - ${city ? `Trade links${city.tradeLinks.size}` : ""} - ${city ? `Trade value${(city.tradeValue || 0).toFixed(2)} / ${Math.round(city.tradeReach || 0)} tiles` : ""} - ${city ? `City knowledge${(city.knowledge?.farming || 0).toFixed(2)} / ${(city.knowledge?.metallurgy || 0).toFixed(2)}` : ""} - ${cityEthnicity ? `City majorityE${cityEthnicity}` : ""} - ${city ? `State${polity ? `#${polity.id}` : "Independent"}` : ""} - ${city ? `Loyalty${city.loyalty.toFixed(2)}` : ""} - ${polity ? `Charisma${clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)}` : ""} - ${polity ? `Leader tenure${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y` : ""} - ${polity ? `Treasury${polity.treasury.toFixed(1)}` : ""} - ${polity ? `Center${polity.centerCityId === city.id ? "yes" : "no"}` : ""} - ${agent ? `EthnicityE${agent.ethnicity}` : ""} - ${ethnicity ? `Climate pref${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : ""} - ${agent ? `Climate mismatch${mismatch.toFixed(2)}` : ""} - ${agent ? `Sedentary${getSedentary(agent.traits).toFixed(2)}` : ""} - ${agent ? `Ethnocentrism${getEthnocentrism(agent.traits).toFixed(2)}` : ""} - ${agent ? `Farming${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : ""} - ${agent ? `Metallurgy${(agent.tech?.metallurgy || 0).toFixed(3)}` : ""} - ${agent ? `Stored${agent.resources.toFixed(1)}` : ""} - ${ethnicity ? `Lineage pop${ethnicity.population}` : ""} + ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} + ${section("Tile", [ + row("Position", `${x}, ${y}`), + row("Terrain", terrain.name), + row("Resources", w.resource[i].toFixed(1)), + row("Fertility", w.fertility[i].toFixed(2)), + row("Minerals", w.mineral[i].toFixed(2)), + row("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`), + row("Water", waterInfluence), + row("Pressure", w.pressure[i].toFixed(0)), + row("Pheromone", w.pheromone[i].toFixed(1)), + w.tradeRoute[i] ? row("Route", w.tradeRoute[i]) : "" + ])} + ${section("City & State", [ + city ? row("Population", city.population.toLocaleString()) : "", + city ? row("Food stock", city.storedResources.toFixed(1)) : "", + city ? row("Supply stress", (city.supplyStress || 0).toFixed(2)) : "", + city ? row("Trade", `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value`) : "", + city ? row("Knowledge", `${(city.knowledge?.farming || 0).toFixed(2)} farm / ${(city.knowledge?.metallurgy || 0).toFixed(2)} metal`) : "", + cityEthnicity ? row("City majority", `E${cityEthnicity}`) : "", + city ? row("State", polity ? `#${polity.id}` : "Independent") : "", + city ? row("Loyalty", city.loyalty.toFixed(2)) : "", + polity ? row("Charisma", clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)) : "", + polity ? row("Leader tenure", `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y`) : "", + polity ? row("Treasury", polity.treasury.toFixed(1)) : "", + polity ? row("Capital", polity.centerCityId === city.id ? "yes" : "no") : "" + ])} + ${section("Culture & Agent", [ + row("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`), + agent ? row("Agent ethnicity", `E${agent.ethnicity}`) : "", + ethnicity ? row("Lineage pop", ethnicity.population) : "", + ethnicity ? row("Climate pref", `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}`) : "", + agent ? row("Climate mismatch", mismatch.toFixed(2)) : "", + agent ? row("Sedentary", getSedentary(agent.traits).toFixed(2)) : "", + agent ? row("Ethnocentrism", getEthnocentrism(agent.traits).toFixed(2)) : "", + agent ? row("Farming", `${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}`) : "", + agent ? row("Metallurgy", (agent.tech?.metallurgy || 0).toFixed(3)) : "", + agent ? row("Stored", agent.resources.toFixed(1)) : "" + ])} `; els.tooltip.hidden = false; const margin = 8; @@ -3723,6 +4045,7 @@ function renderStateGraph() { 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"; @@ -3785,6 +4108,7 @@ function renderStateGraph() { 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; @@ -3887,18 +4211,143 @@ function renderStateGraph() { } }); + + rows.forEach(row => { + if (row.type !== "state") return; + const events = (row.history.events || []) + .filter(event => event.importance >= 2) + .filter(event => event.year >= minYear && event.year <= maxYear) + .filter(event => ["capitalShift", "disaster", "peak", "famine"].includes(event.type)); + const collisions = new Map(); + for (const event of events) { + const x = yearToX(event.year); + const bucket = Math.round(x / 7); + const count = collisions.get(bucket) || 0; + collisions.set(bucket, count + 1); + const y = row.y + ([-4, 0, 4][count % 3]); + const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; + if (event.type === "capitalShift") { + g.fillStyle = "rgba(227, 179, 65, 0.95)"; + drawGraphDiamond(g, x, y, r); + } else if (event.type === "disaster") { + g.fillStyle = "rgba(211, 95, 84, 0.95)"; + drawGraphCircle(g, x, y, r); + } else if (event.type === "peak") { + g.fillStyle = "rgba(238, 241, 237, 0.90)"; + drawGraphTriangleUp(g, x, y, r); + } else if (event.type === "famine") { + g.fillStyle = "rgba(227, 128, 65, 0.90)"; + drawGraphTriangleDown(g, x, y, r); + } + graphState.markers.push({ row, event, x, y, radius: r + 3 }); + } + }); +} + +function drawGraphCircle(g, x, y, r) { + g.beginPath(); + g.arc(x, y, r, 0, Math.PI * 2); + g.fill(); +} + +function drawGraphDiamond(g, x, y, r) { + g.beginPath(); + g.moveTo(x, y - r); + g.lineTo(x + r, y); + g.lineTo(x, y + r); + g.lineTo(x - r, y); + g.closePath(); + g.fill(); +} + +function drawGraphTriangleUp(g, x, y, r) { + g.beginPath(); + g.moveTo(x, y - r); + g.lineTo(x + r, y + r); + g.lineTo(x - r, y + r); + g.closePath(); + g.fill(); +} + +function drawGraphTriangleDown(g, x, y, r) { + g.beginPath(); + g.moveTo(x, y + r); + g.lineTo(x + r, y - r); + g.lineTo(x - r, y - r); + g.closePath(); + g.fill(); +} + +function graphEventLabel(event) { + if (event.type === "capitalShift") return "capital shift"; + if (event.type === "disaster") return "major disaster"; + if (event.type === "peak") return "peak power"; + if (event.type === "famine") return "famine"; + return event.type; +} + +function graphEventSummary(event) { + const data = event.data || {}; + if (event.type === "capitalShift") { + return `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`; + } + if (event.type === "disaster") { + return `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`; + } + if (event.type === "peak") { + return `power ${Number(data.power || 0).toFixed(1)}, pop ${Math.round(data.population || 0).toLocaleString()}, cities ${data.cities || 0}`; + } + if (event.type === "famine") { + return `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`; + } + return ""; +} + +function showStateGraphMarkerTooltip(marker, pointerX, pointerY) { + if (!els.stateGraphTooltip) return; + els.stateGraphTooltip.innerHTML = ` + S${marker.row.history.id} ${graphEventLabel(marker.event)} + ${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) + .sort((a, b) => a.distance - b.distance)[0]?.candidate; + if (marker) { + const status = marker.row.history.active && marker.row.history.ended === null ? "living" : "past"; + els.stateGraphInfo.textContent = `S${marker.row.history.id} ${status} ${formatGraphYear(marker.event.year)} ${graphEventLabel(marker.event)}`; + showStateGraphMarkerTooltip(marker, x, y); + if (graphState.hoverPolityId !== marker.row.history.id) { + graphState.hoverPolityId = marker.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) .sort((a, b) => a.distance - b.distance)[0]?.candidate; if (!row) { els.stateGraphInfo.textContent = "Hover a row for details"; + hideStateGraphMarkerTooltip(); if (graphState.hoverPolityId !== null) { graphState.hoverPolityId = null; if (!running) render(); @@ -3909,6 +4358,7 @@ function updateStateGraphInfo(event) { graphState.hoverPolityId = row.history.id; if (!running) render(); } + hideStateGraphMarkerTooltip(); const year = graphState.scale.xToYear(x); const samples = row.samples.length ? row.samples : [{ @@ -3933,6 +4383,7 @@ function updateStateGraphInfo(event) { function clearStateGraphInfo() { if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details"; + hideStateGraphMarkerTooltip(); if (graphState.hoverPolityId !== null) { graphState.hoverPolityId = null; if (!running) render(); diff --git a/styles.css b/styles.css index 69cc302..1e8814b 100644 --- a/styles.css +++ b/styles.css @@ -131,6 +131,7 @@ select { } .state-graph-scroll { + position: relative; flex: 1; min-height: 0; overflow-y: auto; @@ -140,6 +141,32 @@ select { background: #101315; } +.state-graph-tooltip { + position: absolute; + z-index: 2; + max-width: min(240px, calc(100% - 16px)); + padding: 7px 9px; + border: 1px solid #435057; + border-radius: 6px; + background: rgba(15, 18, 20, 0.50); + color: var(--text); + font-size: 11px; + line-height: 1.3; + pointer-events: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); +} + +.state-graph-tooltip strong { + display: block; + margin-bottom: 4px; + font-size: 12px; +} + +.state-graph-tooltip span { + display: block; + color: var(--muted); +} + .state-graph-info { min-height: 18px; margin-top: 7px; @@ -340,7 +367,7 @@ canvas#world { padding: 8px 10px; border: 1px solid #435057; border-radius: 6px; - background: rgba(15, 18, 20, 0.96); + background: rgba(15, 18, 20, 0.80); color: var(--text); font-size: 11px; line-height: 1.35; @@ -350,10 +377,28 @@ canvas#world { .tooltip strong { display: block; - margin-bottom: 4px; + margin-bottom: 6px; font-size: 12px; } +.tooltip section { + padding-top: 5px; + margin-top: 5px; + border-top: 1px solid rgba(168, 177, 170, 0.16); +} + +.tooltip section:first-of-type { + margin-top: 0; +} + +.tooltip h3 { + margin: 0 0 3px; + color: var(--text); + font-size: 10px; + font-weight: 700; + letter-spacing: 0; +} + .tooltip span { display: flex; justify-content: space-between; @@ -361,6 +406,12 @@ canvas#world { color: var(--muted); } +.tooltip em { + color: var(--text); + font-style: normal; + text-align: right; +} + @media (max-width: 1100px) { body { overflow: auto;