diff --git a/index.html b/index.html index b05c169..e0cbcf9 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@
-
diff --git a/script.js b/script.js index 0853418..60868b0 100644 --- a/script.js +++ b/script.js @@ -18,6 +18,12 @@ const terrainInfo = [ { name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 } ]; +const MONTHS_PER_YEAR = 12; + +function years(value) { + return value * MONTHS_PER_YEAR; +} + const els = { canvas: document.getElementById("world"), sim: document.querySelector(".sim"), @@ -43,7 +49,9 @@ const els = { frameCost: document.getElementById("frameCost"), ethnicityList: document.getElementById("ethnicityList"), legend: document.getElementById("legend"), - tooltip: document.getElementById("tooltip") + tooltip: document.getElementById("tooltip"), + stateGraph: document.getElementById("stateGraph"), + historyRange: document.getElementById("historyRange") }; const ctx = els.canvas.getContext("2d", { alpha: false }); @@ -51,6 +59,7 @@ let sim; let running = true; let frame = 0; let lastStatsAt = 0; +let lastGraphRenderAt = 0; let hoverState = null; class Rng { @@ -369,6 +378,8 @@ class Simulation { this.ethnicities = new Map(); this.cities = []; this.polities = []; + this.polityHistory = new Map(); + this.deadPolityHistories = []; this.tradeLinks = []; this.nextEthnicity = 1; this.nextCity = 1; @@ -532,13 +543,13 @@ class Simulation { this.updateWorldFields(); this.rebuildOccupancy(); - if (this.year % 5 === 0) { + if (this.year % years(5) === 0) { this.updateCities(); this.updateTradeRoutes(); } this.updatePolities(); this.updateEthnicStats(); - if (this.year % 90 === 0) { + if (this.year % years(90) === 0) { this.splitDivergentEthnicities(); this.updateEthnicStats(); } @@ -654,7 +665,9 @@ class Simulation { const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0; const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4; const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1; - a.resources -= (0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep; + const sedentary = getSedentary(a.traits); + const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08; + a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead; if (a.resources <= 0) { a.alive = false; @@ -662,10 +675,15 @@ class Simulation { return; } - if (a.resources > a.traits.reproductionThreshold && offspring.length < 700) { - a.resources *= 0.58; + const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12); + const mobilityPenalty = (1 - sedentary) * 0.45; + const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty - settlementBonus, 0.82, 1.55); + if (a.resources > reproductionThreshold && offspring.length < 700) { + const childShare = 0.36 + sedentary * 0.08; + const childResources = a.resources * childShare; + a.resources -= childResources; const childTraits = mutateTraits(a.traits, this.rng, 0.035); - offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, a.resources * 0.42)); + offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources)); } } @@ -787,15 +805,20 @@ class Simulation { } let foundedThisTick = 0; - const canFoundCities = this.year >= 220 && this.year % 40 === 0; - const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / 1500) : 0; - const candidateEntries = [...candidates].sort((a, b) => b[1].count - a[1].count || b[1].resources - a[1].resources); + const canFoundCities = this.year >= years(180) && this.year % years(30) === 0; + const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(1200)) : 0; + const candidateEntries = [...candidates].sort((a, b) => { + const sedentaryA = a[1].sedentary / Math.max(1, a[1].count); + const sedentaryB = b[1].sedentary / Math.max(1, b[1].count); + return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02); + }); for (const [i, group] of candidateEntries) { if (group.count < 6) continue; const avgSedentary = group.sedentary / group.count; let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 7); if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 50) { - if (avgSedentary < 0.42 || this.rng.next() > avgSedentary * avgSedentary) continue; + const foundingChance = clamp((avgSedentary - 0.28) * 1.55, 0, 0.92); + if (avgSedentary < 0.34 || this.rng.next() > foundingChance) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); this.cities.push(city); foundedThisTick++; @@ -803,8 +826,11 @@ class Simulation { if (city) { city.activeVisitors += group.count; city.storedResources += group.resources * 0.08; + city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02; + const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1); for (const [id, count] of group.ethnicities) { - city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + Math.ceil(count * 0.2)); + const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight); + if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount); } city.strength = city.strength * 0.96 + group.count * 0.05; } @@ -840,9 +866,14 @@ class Simulation { createCity(x, y, seedGroup = null) { const seedPopulation = seedGroup ? Math.max(8, seedGroup.count * 3) : 8; + const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5; const composition = new Map(); if (seedGroup) { - for (const [id, count] of seedGroup.ethnicities) composition.set(id, Math.max(1, count * 3)); + const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1); + for (const [id, count] of seedGroup.ethnicities) { + const urbanCount = this.weightedUrbanContribution(count * 3, urbanWeight); + if (urbanCount > 0) composition.set(id, urbanCount); + } } return { id: this.nextCity++, @@ -857,6 +888,7 @@ class Simulation { activeVisitors: 0, age: 0, strength: 2, + sedentaryCulture: seedSedentary, polityId: null, loyalty: 0.5, receivedAid: false @@ -872,19 +904,29 @@ class Simulation { continue; } const city = this.findCityNear(a.x, a.y, 5); - if (!city || this.rng.next() > getSedentary(a.traits) * 0.55) { + const sedentary = getSedentary(a.traits); + if (!city || this.rng.next() > sedentary * sedentary * 0.75) { absorbed.push(a); continue; } const migrants = 1 + Math.floor(Math.min(5, a.resources / 10)); + const urbanWeight = clamp((sedentary - 0.2) * 1.35, 0.05, 1); city.population += migrants; city.storedResources += Math.max(0, a.resources) * 0.65; - city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + migrants); + const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight); + if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount); + city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015; city.strength += 0.04; } this.agents = absorbed; } + weightedUrbanContribution(amount, weight) { + const value = amount * weight; + const whole = Math.floor(value); + return whole + (this.rng.next() < value - whole ? 1 : 0); + } + processCityEconomies() { const w = this.world; for (const city of this.cities) { @@ -964,7 +1006,8 @@ class Simulation { cityInfluence(city) { if (!city || city.population <= 0 || city.strength <= 0) return 0; - return Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources)); + const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35); + return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor; } getCityById(id) { @@ -1027,9 +1070,77 @@ class Simulation { centerCity.loyalty = 1; centerCity.receivedAid = false; this.polities.push(polity); + this.ensurePolityHistory(polity); + this.samplePolityHistory(polity); return polity; } + ensurePolityHistory(polity) { + if (!polity) return null; + if (!this.polityHistory.has(polity.id)) { + this.polityHistory.set(polity.id, { + id: polity.id, + color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62), + founded: polity.founded ?? this.year, + ended: null, + active: true, + centerCityId: polity.centerCityId ?? null, + fate: null, + samples: [] + }); + } + return this.polityHistory.get(polity.id); + } + + totalPolityPopulation(polity) { + return this.getPolityCities(polity).reduce((sum, city) => sum + city.population, 0); + } + + averagePolityLoyalty(polity) { + const cities = this.getPolityCities(polity); + const subordinates = cities.filter(city => city.id !== polity.centerCityId); + if (!subordinates.length) return 1; + return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length; + } + + samplePolityHistory(polity) { + const history = this.ensurePolityHistory(polity); + if (!history) return; + const cities = this.getPolityCities(polity); + history.centerCityId = polity.centerCityId ?? history.centerCityId; + history.samples.push({ + year: this.year, + cities: cities.length, + population: cities.reduce((sum, city) => sum + city.population, 0), + treasury: polity.treasury || 0, + avgLoyalty: this.averagePolityLoyalty(polity) + }); + while (history.samples.length > 160) history.samples.shift(); + } + + samplePolityHistories() { + for (const polity of this.polities) this.samplePolityHistory(polity); + } + + markPolityEnded(polity, reason = "dissolved") { + if (!polity) return; + const history = this.ensurePolityHistory(polity); + if (!history) return; + this.samplePolityHistory(polity); + history.ended = this.year; + history.active = false; + history.fate = reason; + history.centerCityId = polity.centerCityId ?? history.centerCityId; + this.polityHistory.delete(polity.id); + this.deadPolityHistories.push(history); + while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift(); + } + + getAllPolityHistories() { + return [...this.deadPolityHistories, ...this.polityHistory.values()] + .sort((a, b) => (a.founded - b.founded) || (a.id - b.id)); + } + addCityToPolity(city, polity, initialLoyalty = 0.5) { if (!city || !polity) return; if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city); @@ -1108,6 +1219,21 @@ class Simulation { city.storedResources -= tax; polity.treasury += tax; } + const maintenance = Math.pow(cities.length, 1.15) * 0.18; + if (maintenance > 0) { + const center = this.getCityById(centerId); + const treasuryPayment = Math.min(polity.treasury, maintenance); + polity.treasury -= treasuryPayment; + const unpaid = maintenance - treasuryPayment; + if (unpaid > 0 && center) { + const paidByCenter = this.drainCityResources(center, unpaid); + if (paidByCenter < unpaid) { + for (const city of cities) { + if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.025, 0, 1); + } + } + } + } const poorCount = Math.ceil(cities.length * 0.1); const poorest = [...cities] @@ -1163,14 +1289,20 @@ class Simulation { const survivors = []; for (const polity of this.polities) { const cities = this.getPolityCities(polity); - if (!cities.length) continue; + if (!cities.length) { + this.markPolityEnded(polity, "collapsed"); + continue; + } let center = this.getCityById(polity.centerCityId); if (!center) { center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); polity.centerCityId = center.id; center.loyalty = 1; + const history = this.ensurePolityHistory(polity); + if (history) history.centerCityId = center.id; } if (cities.length === 1) { + this.markPolityEnded(polity, "dissolved"); cities[0].polityId = null; cities[0].loyalty = 0.45; cities[0].receivedAid = false; @@ -1190,14 +1322,43 @@ class Simulation { } updatePolities() { - if (this.year % 12 !== 0) return; + if (this.year % years(10) !== 0) return; this.cleanupPolities(); this.foundPolities(); this.expandPolities(); + this.reinforcePolityTradeRoutes(); this.collectAndRedistributeResources(); this.updateCityLoyalty(); this.splitUnloyalCities(); this.cleanupPolities(); + this.samplePolityHistories(); + } + + reinforcePolityTradeRoutes() { + const w = this.world; + for (const polity of this.polities) { + if (this.rng.next() > 0.32) continue; + const center = this.getCityById(polity.centerCityId); + if (!center) continue; + const candidates = this.getPolityCities(polity) + .filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city)) + .filter(city => this.distanceBetweenCities(center, city) <= 40) + .sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b)); + if (!candidates.length) continue; + + const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))]; + const path = this.findTerrainRoute(center.x, center.y, target.x, target.y); + if (!this.isValidRoutePath(path, target.x, target.y)) continue; + if (!this.payRouteConstructionCost(center, target, path, polity)) continue; + + center.tradeLinks.add(target.id); + target.tradeLinks.add(center.id); + this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path }); + for (const tile of path) { + w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34); + w.pheromone[tile] += 2.4; + } + } } updateTradeRoutes() { @@ -1205,7 +1366,7 @@ class Simulation { for (let i = 0; i < w.count; i++) { const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2; if (invalidTerrain) w.tradeRoute[i] = 0; - else if (w.tradeRoute[i] > 0 && this.year % 4 === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2); + else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2); } this.tradeLinks = []; @@ -1221,6 +1382,7 @@ class Simulation { if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; const strength = this.routeStrengthForPath(path); if (strength < 0.12) continue; + if (!this.canPayRouteConstructionCost(c1, c2, path)) continue; candidates.push({ c1, c2, strength, path }); } } @@ -1234,6 +1396,7 @@ class Simulation { const c1Limit = c1.population > 90 ? 2 : 1; const c2Limit = c2.population > 90 ? 2 : 1; if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; + if (!this.payRouteConstructionCost(c1, c2, path)) continue; c1.tradeLinks.add(c2.id); c2.tradeLinks.add(c1.id); this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path }); @@ -1339,6 +1502,51 @@ class Simulation { this.depositRoutePheromone(path, traffic); } + routeConstructionCost(path, polityBacked = false) { + let weakTiles = 0; + for (const tile of path) { + if (this.world.tradeRoute[tile] < 24) weakTiles++; + } + return weakTiles * (polityBacked ? 0.22 : 0.16); + } + + canPayRouteConstructionCost(cityA, cityB, path, polity = null) { + const cost = this.routeConstructionCost(path, !!polity); + if (cost <= 0) return true; + return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost; + } + + payRouteConstructionCost(cityA, cityB, path, polity = null) { + const cost = this.routeConstructionCost(path, !!polity); + if (cost <= 0) return true; + if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false; + + let remaining = cost; + if (polity) { + const treasuryPayment = Math.min(polity.treasury, cost * 0.65); + polity.treasury -= treasuryPayment; + remaining -= treasuryPayment; + } + + const half = remaining * 0.5; + const paidA = this.drainCityResources(cityA, half); + const paidB = this.drainCityResources(cityB, half); + remaining -= paidA + paidB; + if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); + if (remaining > 0 && polity) { + const treasuryPayment = Math.min(polity.treasury, remaining); + polity.treasury -= treasuryPayment; + remaining -= treasuryPayment; + } + return remaining <= 0.001; + } + + drainCityResources(city, amount) { + const paid = Math.min(city.storedResources, amount); + city.storedResources -= paid; + return paid; + } + depositRoutePheromone(path, amount) { const w = this.world; for (const i of path) { @@ -1470,7 +1678,9 @@ class Simulation { treasury: p.treasury, color: p.color, founded: p.founded - })) + })), + polityHistory: [...this.polityHistory.values()], + deadPolityHistories: this.deadPolityHistories }; } @@ -1525,6 +1735,7 @@ class Simulation { activeVisitors: 0, age: c.age || 0, strength: c.strength || 1, + sedentaryCulture: c.sedentaryCulture ?? 0.5, polityId: c.polityId ?? null, loyalty: c.loyalty ?? 0.5, receivedAid: c.receivedAid ?? false @@ -1537,6 +1748,27 @@ class Simulation { color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62), founded: p.founded || 0 })); + 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), + founded: h.founded || 0, + ended: h.ended ?? null, + active: h.active !== false, + centerCityId: h.centerCityId ?? null, + fate: h.fate || null, + samples: h.samples || [] + }])); + sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({ + id: h.id, + color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), + founded: h.founded || 0, + ended: h.ended ?? null, + active: false, + centerCityId: h.centerCityId ?? null, + fate: h.fate || null, + samples: h.samples || [] + })); + for (const polity of sim.polities) sim.ensurePolityHistory(polity); sim.tradeLinks = []; sim.rebuildOccupancy(); sim.updateTradeRoutes(); @@ -1588,6 +1820,7 @@ function render() { drawTradeLinks(); drawFarmlandRings(mode); drawAgentsAndCities(mode); + maybeRenderStateGraph(); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } @@ -1676,9 +1909,12 @@ function cityMajorityColor(city) { } function cityDisplayColor(city, mode) { - if (mode === "polities" && city.polityId !== null) { - const polity = sim.getPolityById(city.polityId); - if (polity) return polity.color; + if (mode === "polities") { + if (city.polityId !== null) { + const polity = sim.getPolityById(city.polityId); + if (polity) return polity.color; + } + return [218, 205, 154]; } return cityMajorityColor(city); } @@ -1792,7 +2028,7 @@ function updateStats(force = false) { lastStatsAt = now; const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0); const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0); - els.year.textContent = sim.year.toLocaleString(); + els.year.textContent = formatSimDate(sim.year); els.population.textContent = Math.floor(sim.agents.length + urbanPopulation).toLocaleString(); els.activeGroups.textContent = sim.agents.length.toLocaleString(); els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString(); @@ -1807,6 +2043,7 @@ function updateStats(force = false) { `
  • ` + `E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}
  • ` )).join(""); + maybeRenderStateGraph(); } function setLegend() { @@ -1828,6 +2065,164 @@ function setLegend() { } } +function maybeRenderStateGraph() { + const now = performance.now(); + if (now - lastGraphRenderAt < 500) return; + lastGraphRenderAt = now; + renderStateGraph(); +} + +function renderStateGraph() { + const canvas = els.stateGraph; + if (!canvas || !sim) return; + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, Math.floor(rect.width * dpr)); + const height = Math.max(1, Math.floor(rect.height * 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; + const h = rect.height; + g.clearRect(0, 0, w, h); + g.fillStyle = "#101315"; + g.fillRect(0, 0, w, h); + + const historyWindowStart = Math.max(0, sim.year - years(10000)); + const histories = sim.getAllPolityHistories() + .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); + if (!histories.length) { + if (els.historyRange) els.historyRange.textContent = "-"; + g.fillStyle = "#7f8984"; + g.font = "12px ui-sans-serif, system-ui, sans-serif"; + g.fillText("No state history yet", 16, 28); + return; + } + + let minYear = Infinity; + let maxYear = sim.year; + for (const history of histories) { + minYear = Math.min(minYear, history.visibleFounded); + maxYear = Math.max(maxYear, history.ended ?? 0, history.visibleSamples[history.visibleSamples.length - 1]?.year ?? 0); + } + if (!Number.isFinite(minYear)) minYear = 0; + if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; + + const paddingLeft = 34; + const paddingRight = 10; + const paddingTop = 18; + const paddingBottom = 22; + const plotWidth = Math.max(1, w - paddingLeft - paddingRight); + const rowHeight = 22; + const maxRows = Math.max(1, Math.floor((h - paddingTop - paddingBottom) / rowHeight)); + const selected = histories + .map(history => { + const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0 }]; + const peakPopulation = Math.max(...samples.map(sample => sample.population || 0)); + const peakCities = Math.max(...samples.map(sample => sample.cities || 0)); + const lifespan = (history.ended ?? sim.year) - history.visibleFounded; + return { history, peakPopulation, peakCities, importance: peakPopulation + peakCities * 100 + lifespan * 2 }; + }) + .sort((a, b) => b.importance - a.importance) + .slice(0, maxRows) + .sort((a, b) => (a.history.founded - b.history.founded) || (a.history.id - b.history.id)); + + const yearToX = year => paddingLeft + ((year - minYear) / Math.max(1, maxYear - minYear)) * plotWidth; + const axisY = h - paddingBottom + 4; + g.strokeStyle = "rgba(168, 177, 170, 0.22)"; + g.lineWidth = 1; + g.beginPath(); + g.moveTo(paddingLeft, axisY); + g.lineTo(w - paddingRight, axisY); + g.stroke(); + 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); + + selected.forEach((item, rowIndex) => { + const history = item.history; + const y = paddingTop + rowIndex * rowHeight + rowHeight * 0.5; + const color = history.color || hslToRgb((history.id * 0.38196601125) % 1, 0.58, 0.62); + const startX = yearToX(history.visibleFounded); + const endX = yearToX(history.ended ?? sim.year); + const alpha = history.active ? 0.82 : 0.4; + const thickness = clamp(2 + Math.sqrt(item.peakCities) * 1.2, 2, 9); + + g.textAlign = "left"; + g.fillStyle = "#a8b1aa"; + g.font = "10px ui-sans-serif, system-ui, sans-serif"; + g.fillText(`S${history.id}`, 4, y + 3); + + g.strokeStyle = "rgba(168, 177, 170, 0.12)"; + g.lineWidth = 1; + g.beginPath(); + g.moveTo(paddingLeft, y); + g.lineTo(w - paddingRight, y); + g.stroke(); + + g.strokeStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${alpha})`; + g.lineWidth = thickness; + g.lineCap = "round"; + g.beginPath(); + g.moveTo(startX, y); + g.lineTo(endX, y); + g.stroke(); + g.lineCap = "butt"; + + for (const sample of history.visibleSamples) { + const sx = yearToX(sample.year); + const radius = clamp(1 + Math.sqrt(sample.cities || 0) * 0.5, 1.5, 4); + const opacity = 0.35 + clamp(sample.avgLoyalty ?? 0.5, 0, 1) * 0.55; + g.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`; + g.beginPath(); + g.arc(sx, y, radius, 0, Math.PI * 2); + g.fill(); + } + + if (history.ended !== null) { + g.strokeStyle = "rgba(224, 105, 94, 0.8)"; + g.lineWidth = 1.5; + g.beginPath(); + g.moveTo(endX - 3, y - 3); + g.lineTo(endX + 3, y + 3); + g.moveTo(endX + 3, y - 3); + g.lineTo(endX - 3, y + 3); + g.stroke(); + } else { + g.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; + g.beginPath(); + g.arc(endX, y, 3, 0, Math.PI * 2); + g.fill(); + } + }); +} + +function formatGraphYear(month) { + return `${Math.floor(month / MONTHS_PER_YEAR).toLocaleString()}y`; +} + +function formatSimDate(month) { + const year = Math.floor(month / MONTHS_PER_YEAR); + const monthOfYear = month % MONTHS_PER_YEAR + 1; + return `${year.toLocaleString()}y ${monthOfYear}m`; +} + function loop() { const steps = running ? Number(els.speed.value) : 0; for (let i = 0; i < steps; i++) sim.step(); @@ -1849,6 +2244,7 @@ function reset() { setLegend(); render(); updateStats(true); + renderStateGraph(); } function clamp(v, min, max) { @@ -2024,6 +2420,7 @@ function loadWorld() { setLegend(); render(); updateStats(true); + renderStateGraph(); } catch (error) { console.warn("Load failed", error); } @@ -2037,6 +2434,7 @@ els.stepOnce.addEventListener("click", () => { sim.step(); render(); updateStats(true); + renderStateGraph(); renderTooltip(); }); els.resetWorld.addEventListener("click", reset); diff --git a/styles.css b/styles.css index 3c9d1d2..48ae1e8 100644 --- a/styles.css +++ b/styles.css @@ -37,7 +37,7 @@ select { .app { display: grid; - grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) minmax(260px, 340px); width: 100%; height: 100%; } @@ -53,6 +53,54 @@ select { background: #16191b; } +.history-sidebar { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 14px; + overflow: hidden; + border-left: 1px solid var(--line); + background: #16191b; +} + +.history-panel { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.history-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.history-header h2 { + margin: 0; + font-size: 14px; +} + +.history-header span { + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +#stateGraph { + display: block; + width: 100%; + flex: 1; + min-height: 420px; + border: 1px solid var(--line); + border-radius: 6px; + background: #101315; + image-rendering: auto; +} + .brand h1 { margin: 0 0 6px; font-size: 24px; @@ -191,12 +239,13 @@ select { background: #101214; } -canvas { +canvas#world { display: block; - width: min(calc(100vh - 32px), calc(100vw - 380px)); - height: min(calc(100vh - 32px), calc(100vw - 380px)); + width: min(calc(100vh - 32px), 100%); + height: min(calc(100vh - 32px), 100%); max-width: 100%; max-height: 100%; + aspect-ratio: 1 / 1; border: 1px solid #293035; background: #0b0d0f; image-rendering: pixelated; @@ -260,7 +309,7 @@ canvas { color: var(--muted); } -@media (max-width: 820px) { +@media (max-width: 1100px) { body { overflow: auto; } @@ -276,13 +325,24 @@ canvas { border-bottom: 1px solid var(--line); } + .history-sidebar { + border-left: 0; + border-top: 1px solid var(--line); + overflow: visible; + } + .sim { min-height: 70vh; padding: 10px; } - canvas { + canvas#world { width: min(calc(100vw - 20px), 92vh); height: min(calc(100vw - 20px), 92vh); } + + #stateGraph { + height: 420px; + flex: none; + } }