diff --git a/index.html b/index.html
index 96451c4..ff778b3 100644
--- a/index.html
+++ b/index.html
@@ -94,7 +94,34 @@
State lifespans
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Hover a row for details
diff --git a/script.js b/script.js
index 08daf48..51ce9ac 100644
--- a/script.js
+++ b/script.js
@@ -99,7 +99,13 @@ const els = {
legend: document.getElementById("legend"),
tooltip: document.getElementById("tooltip"),
stateGraph: document.getElementById("stateGraph"),
- historyRange: document.getElementById("historyRange")
+ historyRange: document.getElementById("historyRange"),
+ historyFilter: document.getElementById("historyFilter"),
+ historySort: document.getElementById("historySort"),
+ historyMetric: document.getElementById("historyMetric"),
+ toggleActiveStates: document.getElementById("toggleActiveStates"),
+ togglePastStates: document.getElementById("togglePastStates"),
+ stateGraphInfo: document.getElementById("stateGraphInfo")
};
const ctx = els.canvas.getContext("2d", { alpha: false });
@@ -111,6 +117,16 @@ let lastGraphRenderAt = 0;
let hoverState = null;
let renderImage = null;
let renderImageSize = 0;
+const graphState = {
+ filter: "all",
+ sort: "important",
+ metric: "power",
+ showActive: true,
+ showPast: true,
+ hoverPolityId: null,
+ rows: [],
+ scale: null
+};
class Rng {
constructor(seed) {
@@ -434,11 +450,17 @@ class Simulation {
this.agents = [];
this.ethnicities = new Map();
this.cities = [];
+ this.cityById = new Map();
+ this.cityBuckets = new Map();
this.polities = [];
+ this.polityById = new Map();
this.wars = [];
this.polityHistory = new Map();
this.deadPolityHistories = [];
this.tradeLinks = [];
+ this.activeTradeRouteTiles = new Set();
+ this.ethnicDensityCache = new Map();
+ this.dominantEthnicityCache = new Map();
this.nextEthnicity = 1;
this.nextCity = 1;
this.nextPolity = 1;
@@ -597,6 +619,7 @@ class Simulation {
step() {
for (const city of this.cities) city.activeVisitors = 0;
this.rebuildOccupancy();
+ this.ethnicDensityCache.clear();
const offspring = [];
for (const a of this.agents) {
@@ -604,13 +627,14 @@ class Simulation {
this.moveAgent(a);
}
this.rebuildOccupancy();
+ this.dominantEthnicityCache.clear();
for (const a of this.agents) {
if (!a.alive) continue;
this.updateAgentTechnology(a);
this.gatherConsumeReproduce(a, offspring);
if (a.alive) this.payTechnologyCostOrForget(a);
- this.resolveAssimilation(a);
+ if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a);
}
this.agents = this.agents.filter(a => a.alive);
@@ -620,10 +644,10 @@ class Simulation {
this.agents.push(...acceptedOffspring);
for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
- this.updateWorldFields();
+ if (this.year % 2 === 0) this.updateWorldFields(2);
if (this.year % years(1) === 0) {
this.updateCities();
- this.updateRegionalCultures();
+ if (this.year % years(2) === 0) this.updateRegionalCultures();
}
if (this.year % years(5) === 0) {
this.updateTradeRoutes();
@@ -642,6 +666,7 @@ class Simulation {
w.pressure.fill(0);
this.tileEthnicities.clear();
this.tileAgents.clear();
+ this.rebuildIndexes();
for (const a of this.agents) {
if (!a.alive) continue;
const i = w.idx(a.x, a.y);
@@ -662,6 +687,43 @@ class Simulation {
for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile);
}
+ rebuildIndexes() {
+ this.cityById = new Map(this.cities.map(city => [city.id, city]));
+ this.polityById = new Map(this.polities.map(polity => [polity.id, polity]));
+ this.cityBuckets = new Map();
+ for (const city of this.cities) {
+ const key = this.cityBucketKey(city.x, city.y);
+ let bucket = this.cityBuckets.get(key);
+ if (!bucket) {
+ bucket = [];
+ this.cityBuckets.set(key, bucket);
+ }
+ bucket.push(city);
+ }
+ }
+
+ cityBucketKey(x, y) {
+ return `${Math.floor(x / 8)},${Math.floor(y / 8)}`;
+ }
+
+ getCitiesNear(x, y, radius) {
+ const cities = [];
+ const minBx = Math.floor((x - radius) / 8);
+ const maxBx = Math.floor((x + radius) / 8);
+ const minBy = Math.floor((y - radius) / 8);
+ const maxBy = Math.floor((y + radius) / 8);
+ for (let by = minBy; by <= maxBy; by++) {
+ for (let bx = minBx; bx <= maxBx; bx++) {
+ const bucket = this.cityBuckets.get(`${bx},${by}`);
+ if (!bucket) continue;
+ for (const city of bucket) {
+ if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city);
+ }
+ }
+ }
+ return cities;
+ }
+
updateCultureTile(tile) {
const counts = this.tileEthnicities.get(tile);
if (!counts || !counts.size) {
@@ -823,7 +885,9 @@ class Simulation {
const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0);
if (isWater && this.rng.next() > waterAccess) continue;
- const ethnicDensity = this.ethnicDensityNear(x, y, a.ethnicity);
+ const ethnicDensity = ethnocentrism > 0.04
+ ? this.ethnicDensityNear(x, y, a.ethnicity)
+ : { same: 0, foreign: 0 };
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
@@ -913,12 +977,18 @@ class Simulation {
this.ensureAgentTech(agent);
this.updateFarmingWork(agent);
this.tryInventTechnology(agent);
- this.spreadTechnology(agent);
this.learnTechnologyFromCity(agent);
- this.improveTechnologyFromDensity(agent);
+ if (this.shouldRunAgentSocial(agent, 3)) {
+ this.spreadTechnology(agent);
+ this.improveTechnologyFromDensity(agent);
+ }
if (payMaintenance) this.payTechnologyCostOrForget(agent);
}
+ shouldRunAgentSocial(agent, interval) {
+ return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0;
+ }
+
updateFarmingWork(agent) {
const w = this.world;
const tile = w.idx(agent.x, agent.y);
@@ -1185,6 +1255,9 @@ class Simulation {
}
ethnicDensityNear(x, y, ethnicity) {
+ const key = `${x},${y},${ethnicity}`;
+ const cached = this.ethnicDensityCache.get(key);
+ if (cached) return cached;
let same = 0;
let foreign = 0;
const w = this.world;
@@ -1200,10 +1273,16 @@ class Simulation {
if (id === ethnicity) same += value;
else foreign += value;
}
- if (same > 6 && foreign > 6) return { same: same * 0.22, foreign: foreign * 0.16 };
+ if (same > 6 && foreign > 6) {
+ const result = { same: same * 0.22, foreign: foreign * 0.16 };
+ this.ethnicDensityCache.set(key, result);
+ return result;
+ }
}
}
- return { same: same * 0.22, foreign: foreign * 0.16 };
+ const result = { same: same * 0.22, foreign: foreign * 0.16 };
+ this.ethnicDensityCache.set(key, result);
+ return result;
}
resolveAssimilation(a) {
@@ -1229,6 +1308,9 @@ class Simulation {
}
dominantEthnicityNear(x, y, self) {
+ const key = `${x},${y},${self}`;
+ const cached = this.dominantEthnicityCache.get(key);
+ if (cached) return cached;
const counts = new Map();
let total = 0;
const w = this.world;
@@ -1246,9 +1328,9 @@ class Simulation {
}
}
}
- for (const city of this.cities) {
+ for (const city of this.getCitiesNear(x, y, 5)) {
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
- if (distance > 5 || !city.ethnicityComposition?.size) continue;
+ if (!city.ethnicityComposition?.size) continue;
const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8);
for (const [id, count] of city.ethnicityComposition) {
const weighted = Math.max(1, Math.round(count * influence * 0.08));
@@ -1261,14 +1343,16 @@ class Simulation {
if (id !== self && (!best || count > best.count)) best = { id, count, total };
}
if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
+ if (best) this.dominantEthnicityCache.set(key, best);
return best;
}
- updateWorldFields() {
+ updateWorldFields(scale = 1) {
const w = this.world;
+ const pheromoneDecay = Math.pow(0.996, scale);
for (let i = 0; i < w.count; i++) {
- w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 1.15));
- w.pheromone[i] *= 0.996;
+ w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15));
+ w.pheromone[i] *= pheromoneDecay;
}
}
@@ -1313,12 +1397,16 @@ class Simulation {
for (const [i, group] of candidateEntries) {
if (group.count < 3) 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);
+ let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null;
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) {
const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98);
if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue;
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
this.cities.push(city);
+ this.cityById.set(city.id, city);
+ const bucketKey = this.cityBucketKey(city.x, city.y);
+ if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
+ this.cityBuckets.get(bucketKey).push(city);
foundedThisTick++;
}
if (city) {
@@ -1369,6 +1457,7 @@ class Simulation {
}
return true;
});
+ this.rebuildIndexes();
}
createCity(x, y, seedGroup = null) {
@@ -1400,7 +1489,9 @@ class Simulation {
supplyStress: 0,
polityId: null,
loyalty: 0.5,
- receivedAid: false
+ receivedAid: false,
+ tradeValue: 0,
+ tradeReach: 0
};
}
@@ -1464,16 +1555,30 @@ class Simulation {
}
city.storedResources += harvested;
+ const trade = this.cityTradeProfile(city);
+ const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018);
+ city.storedResources += tradeIncome;
const supportRatio = city.activeVisitors / Math.max(1, city.population);
const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
- const upkeep = city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09) + knowledgeMaintenance;
+ const tradeRelief = clamp(trade.value * 0.09, 0, 0.32);
+ const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance;
city.storedResources -= upkeep;
const foodPerCapita = city.storedResources / Math.max(1, city.population);
- city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8, 0, 1.8);
+ city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8);
this.innovateCityKnowledge(city, harvested);
+ if (trade.value > 0) {
+ city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1);
+ city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1);
+ city.strength += clamp(trade.value * 0.018, 0, 0.08);
+ city.tradeValue = trade.value;
+ city.tradeReach = trade.reach;
+ } else {
+ city.tradeValue = 0;
+ city.tradeReach = 0;
+ }
if (city.storedResources > city.population * 0.16 && city.population > 0) {
- const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12, 0, 0.8);
- const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012)));
+ const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8);
+ const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015)));
city.population += births;
city.storedResources -= births * 0.55;
addBirthsToComposition(city.ethnicityComposition, births);
@@ -1501,6 +1606,28 @@ class Simulation {
}
}
+ cityTradeProfile(city) {
+ if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 };
+ let value = 0;
+ let reach = 0;
+ for (const link of this.tradeLinks) {
+ if (link.from !== city.id && link.to !== city.id) continue;
+ const other = this.getCityById(link.from === city.id ? link.to : link.from);
+ if (!other) continue;
+ const distance = this.distanceBetweenCities(city, other);
+ const distanceFactor = clamp((distance - 14) / 30, 0, 1);
+ if (distanceFactor <= 0) continue;
+ const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4);
+ const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6);
+ value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale;
+ reach = Math.max(reach, distance);
+ }
+ return {
+ value: clamp(value, 0, 3.2),
+ reach
+ };
+ }
+
innovateCityKnowledge(city, harvested) {
city.knowledge ??= { farming: 0, metallurgy: 0 };
const scale = SimConfig.technology.cityInnovation;
@@ -1539,7 +1666,7 @@ class Simulation {
findCityNear(x, y, radius) {
let best = null;
let bestDistance = Infinity;
- for (const c of this.cities) {
+ for (const c of this.getCitiesNear(x, y, radius)) {
const d = Math.abs(c.x - x) + Math.abs(c.y - y);
if (d <= radius && d < bestDistance) {
best = c;
@@ -1556,11 +1683,19 @@ class Simulation {
}
getCityById(id) {
- return this.cities.find(c => c.id === id) || null;
+ const cached = this.cityById?.get(id);
+ if (cached) return cached;
+ const city = this.cities.find(c => c.id === id) || null;
+ if (city) this.cityById.set(id, city);
+ return city;
}
getPolityById(id) {
- return this.polities.find(p => p.id === id) || null;
+ const cached = this.polityById?.get(id);
+ if (cached) return cached;
+ const polity = this.polities.find(p => p.id === id) || null;
+ if (polity) this.polityById.set(id, polity);
+ return polity;
}
getPolityCities(polity) {
@@ -1619,6 +1754,7 @@ class Simulation {
centerCity.loyalty = 1;
centerCity.receivedAid = false;
this.polities.push(polity);
+ this.polityById.set(polity.id, polity);
this.ensurePolityHistory(polity);
this.samplePolityHistory(polity);
return polity;
@@ -1652,13 +1788,20 @@ class Simulation {
const history = this.ensurePolityHistory(polity);
if (!history) return;
const cities = this.getPolityCities(polity);
+ const population = cities.reduce((sum, city) => sum + city.population, 0);
+ const treasury = polity.treasury || 0;
+ const avgLoyalty = this.averagePolityLoyalty(polity);
+ const fallbackPower = Math.sqrt(population) * 1.35 +
+ Math.sqrt(Math.max(0, treasury)) * 1.15 +
+ avgLoyalty * 16;
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)
+ population,
+ treasury,
+ avgLoyalty,
+ power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower
});
const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
while (history.samples.length > maxSamples) history.samples.shift();
@@ -1740,7 +1883,8 @@ class Simulation {
}
endWar(war) {
- if (war && war.ended === null) war.ended = this.year;
+ if (!war || war.ended !== null) return;
+ war.ended = this.year;
}
totalPolityPopulation(polity) {
@@ -2099,7 +2243,7 @@ class Simulation {
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
- war.ended = this.year;
+ this.endWar(war);
continue;
}
@@ -2243,7 +2387,7 @@ class Simulation {
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
- war.ended = this.year;
+ this.endWar(war);
return;
}
@@ -2252,17 +2396,17 @@ class Simulation {
const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0);
if (age > years(90)) {
- war.ended = this.year;
+ this.endWar(war);
return;
}
if (age > years(10) && noActionFor > years(18)) {
- war.ended = this.year;
+ this.endWar(war);
return;
}
if (exhaustion > 0.85 && this.rng.next() < 0.35) {
- war.ended = this.year;
+ this.endWar(war);
}
}
@@ -2488,6 +2632,7 @@ class Simulation {
survivors.push(polity);
}
this.polities = survivors;
+ this.rebuildIndexes();
const validPolities = new Set(this.polities.map(p => p.id));
for (const city of this.cities) {
if (city.polityId !== null && !validPolities.has(city.polityId)) {
@@ -2566,23 +2711,37 @@ class Simulation {
this.tradeLinks = [];
for (const city of this.cities) city.tradeLinks.clear();
- const candidates = [];
+ const pairCandidates = [];
+ const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400);
+ const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)]));
for (let a = 0; a < this.cities.length; a++) {
for (let b = a + 1; b < this.cities.length; b++) {
const c1 = this.cities[a];
const c2 = this.cities[b];
const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
if (d > 44) continue;
- const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
- if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
- const strength = this.routeStrengthForPath(path);
- if (strength < 0.08) continue;
- if (!this.canPayRouteConstructionCost(c1, c2, path)) continue;
- candidates.push({ c1, c2, strength, path });
+ const distanceFactor = clamp((d - 14) / 30, 0, 1);
+ const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1);
+ const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0;
+ const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias;
+ pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore });
}
}
+ pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore);
- candidates.sort((a, b) => b.strength - a.strength);
+ const candidates = [];
+ for (const pair of pairCandidates.slice(0, maxPairsToRoute)) {
+ const { c1, c2, distanceFactor, marketScale } = pair;
+ const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
+ if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
+ const strength = this.routeStrengthForPath(path);
+ if (strength < 0.08) continue;
+ if (!this.canPayRouteConstructionCost(c1, c2, path)) continue;
+ const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale;
+ candidates.push({ c1, c2, strength, path, tradeScore });
+ }
+
+ candidates.sort((a, b) => b.tradeScore - a.tradeScore);
const maxLinks = Math.max(1, Math.floor(this.cities.length / 2));
const supportedRoutes = new Set();
for (const candidate of candidates) {
@@ -2605,6 +2764,10 @@ class Simulation {
for (let i = 0; i < w.count; i++) {
if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4);
}
+ this.activeTradeRouteTiles = new Set();
+ for (let i = 0; i < w.count; i++) {
+ if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i);
+ }
this.diffuseCityKnowledgeThroughTrade();
}
@@ -2969,7 +3132,9 @@ class Simulation {
supplyStress: c.supplyStress ?? 0,
polityId: c.polityId ?? null,
loyalty: c.loyalty ?? 0.5,
- receivedAid: c.receivedAid ?? false
+ receivedAid: c.receivedAid ?? false,
+ tradeValue: c.tradeValue ?? 0,
+ tradeReach: c.tradeReach ?? 0
}));
sim.polities = (state.polities || []).map(p => ({
id: p.id,
@@ -2983,6 +3148,7 @@ class Simulation {
crisis: p.crisis ?? 0,
lastCrisisYear: p.lastCrisisYear ?? sim.year
}));
+ sim.rebuildIndexes();
sim.wars = (state.wars || []).map(w => ({
id: w.id,
aPolityId: w.aPolityId,
@@ -3018,6 +3184,7 @@ class Simulation {
}));
for (const polity of sim.polities) sim.ensurePolityHistory(polity);
sim.tradeLinks = [];
+ sim.activeTradeRouteTiles = new Set();
sim.rebuildOccupancy();
sim.updateTradeRoutes();
sim.cleanupPolities();
@@ -3037,6 +3204,19 @@ function render() {
const image = renderImage;
const data = image.data;
const mode = els.viewMode.value;
+ const cityPolityColor = new Map();
+ if (mode === "polities") {
+ for (const city of sim.cities) {
+ if (city.polityId === null) continue;
+ const polity = sim.getPolityById(city.polityId);
+ if (!polity) continue;
+ const isGraphHover = graphState.hoverPolityId === polity.id;
+ cityPolityColor.set(city.id, {
+ color: polity.color,
+ isGraphHover
+ });
+ }
+ }
for (let i = 0; i < w.count; i++) {
let color;
@@ -3055,9 +3235,11 @@ function render() {
} else if (mode === "polities") {
color = terrainInfo[w.terrain[i]].color;
if (w.city[i] >= 0) {
- const city = sim.getCityById(w.city[i]);
- const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
- if (polity) color = mix(color, polity.color, 0.5);
+ const polityColor = cityPolityColor.get(w.city[i]);
+ if (polityColor) {
+ color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5);
+ if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28);
+ }
}
} else if (mode === "technology") {
color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
@@ -3074,6 +3256,7 @@ function render() {
ctx.putImageData(image, 0, 0);
drawTradeLinks();
drawAgentsAndCities(mode);
+ drawGraphPolityHighlight();
maybeRenderStateGraph();
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
}
@@ -3083,8 +3266,8 @@ function drawTradeLinks() {
ctx.save();
ctx.globalAlpha = 0.24;
ctx.fillStyle = "#d9b650";
- for (let i = 0; i < w.count; i++) {
- if (w.tradeRoute[i]) ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
+ for (const i of sim.activeTradeRouteTiles || []) {
+ ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
}
if (!sim.tradeLinks.length) {
ctx.restore();
@@ -3111,13 +3294,8 @@ function drawAgentsAndCities(mode) {
}
ctx.globalAlpha = 1;
- for (const a of sim.agents) {
- if (mode === "ethnicity") {
- const e = sim.ethnicities.get(a.ethnicity);
- if (!e) continue;
- ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
- ctx.fillRect(a.x, a.y, 1, 1);
- } else if (mode === "technology") {
+ if (mode === "technology") {
+ for (const a of sim.agents) {
sim.ensureAgentTech(a);
const farming = a.tech.farming || 0;
const metallurgy = a.tech.metallurgy || 0;
@@ -3126,9 +3304,26 @@ function drawAgentsAndCities(mode) {
const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`;
ctx.fillRect(a.x, a.y, 1, 1);
- } else {
- ctx.fillStyle = "#eeeccf";
- ctx.fillRect(a.x, a.y, 1, 1);
+ }
+ } else if (mode === "ethnicity") {
+ for (const [tile, counts] of sim.tileEthnicities) {
+ let bestId = null;
+ let bestCount = 0;
+ for (const [id, count] of counts) {
+ if (count > bestCount) {
+ bestId = id;
+ bestCount = count;
+ }
+ }
+ const e = bestId !== null ? sim.ethnicities.get(bestId) : null;
+ if (!e) continue;
+ ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
+ ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
+ }
+ } else {
+ ctx.fillStyle = "#eeeccf";
+ for (const tile of sim.tileAgents.keys()) {
+ ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
}
}
@@ -3140,6 +3335,29 @@ function drawAgentsAndCities(mode) {
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(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`;
+ ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
+ ctx.globalAlpha = 0.32;
+ ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
+ 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) {
return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7);
}
@@ -3188,7 +3406,7 @@ function renderTooltip() {
const w = sim.world;
const i = w.idx(x, y);
const agent = findAgentAt(x, y);
- const city = w.city[i] >= 0 ? sim.cities.find(c => c.id === w.city[i]) : null;
+ const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null;
const terrain = terrainInfo[w.terrain[i]];
const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
@@ -3216,6 +3434,7 @@ function renderTooltip() {
${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"}` : ""}
@@ -3251,12 +3470,7 @@ function hideTooltip() {
function findAgentAt(x, y) {
const directIndex = sim.world.idx(x, y);
- const directCounts = sim.tileEthnicities.get(directIndex);
- if (!directCounts) return null;
- for (const a of sim.agents) {
- if (a.x === x && a.y === y) return a;
- }
- return null;
+ return sim.tileAgents.get(directIndex)?.[0] || null;
}
function waterInfluenceAt(world, x, y) {
@@ -3336,30 +3550,39 @@ function maybeRenderStateGraph() {
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;
+}
+
+function graphSampleValue(sample, metric) {
+ if (metric === "population") return sample?.population || 0;
+ if (metric === "cities") return sample?.cities || 0;
+ if (metric === "loyalty") return sample?.avgLoyalty ?? 0.5;
+ return sampleGraphPower(sample);
+}
+
+function formatGraphValue(value, metric) {
+ if (metric === "loyalty") return `${Math.round(clamp(value, 0, 1) * 100)}%`;
+ if (metric === "cities") return Math.round(value).toLocaleString();
+ if (metric === "population") return Math.round(value).toLocaleString();
+ return value.toFixed(1);
+}
+
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);
+ if (!rect.width) return;
const historyWindowSpan = years(SimConfig.render.historyWindowYears);
const historyWindowEnd = sim.year;
const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan);
+ graphState.filter = els.historyFilter?.value || graphState.filter;
+ graphState.sort = els.historySort?.value || graphState.sort;
+ graphState.metric = els.historyMetric?.value || graphState.metric;
const histories = sim.getAllPolityHistories()
.filter(history => (history.ended ?? sim.year) >= historyWindowStart)
.map(history => ({
@@ -3367,9 +3590,88 @@ function renderStateGraph() {
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) {
+ .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);
+ }
+ const activeDelta = (b.history.active ? 1 : 0) - (a.history.active ? 1 : 0);
+ if (graphState.sort === "active") {
+ return activeDelta || (b.importance - a.importance) || (a.history.id - b.history.id);
+ }
+ return activeDelta || (b.importance - a.importance) || ((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: `Living ${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: `Past ${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.scale = null;
g.fillStyle = "#7f8984";
g.font = "12px ui-sans-serif, system-ui, sans-serif";
g.fillText("No state history yet", 16, 28);
@@ -3378,31 +3680,61 @@ function renderStateGraph() {
let minYear = historyWindowStart;
let maxYear = historyWindowEnd;
- for (const history of histories) {
+ 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 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 + ((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.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear };
- 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;
@@ -3419,62 +3751,140 @@ function renderStateGraph() {
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);
-
+ rows.forEach(row => {
+ 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, 0.16)";
+ g.lineWidth = 1;
+ g.beginPath();
+ g.moveTo(paddingLeft, row.y);
+ g.lineTo(w - paddingRight, row.y);
+ g.stroke();
+ return;
+ }
g.strokeStyle = "rgba(168, 177, 170, 0.12)";
g.lineWidth = 1;
g.beginPath();
- g.moveTo(paddingLeft, y);
- g.lineTo(w - paddingRight, y);
+ g.moveTo(paddingLeft, row.y);
+ g.lineTo(w - paddingRight, row.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();
- }
});
+
+ 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(`S${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)})`;
+ g.beginPath();
+ g.moveTo(yearToX(fromYear), row.y);
+ g.lineTo(yearToX(toYear), row.y);
+ g.stroke();
+ g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`;
+ g.lineWidth = Math.max(2, thickness * 0.62);
+ g.beginPath();
+ g.moveTo(yearToX(fromYear), row.y);
+ g.lineTo(yearToX(toYear), row.y);
+ g.stroke();
+ 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);
+ }
+
+ });
+}
+
+function updateStateGraphInfo(event) {
+ if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return;
+ const y = event.offsetY;
+ const x = event.offsetX;
+ 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";
+ if (graphState.hoverPolityId !== null) {
+ graphState.hoverPolityId = null;
+ if (!running) render();
+ }
+ return;
+ }
+ if (graphState.hoverPolityId !== row.history.id) {
+ graphState.hoverPolityId = row.history.id;
+ if (!running) render();
+ }
+
+ const year = graphState.scale.xToYear(x);
+ const samples = row.samples.length ? row.samples : [{
+ year,
+ population: 0,
+ cities: row.peakCities,
+ avgLoyalty: 0.5,
+ power: row.peakPower
+ }];
+ const sample = samples
+ .map(candidate => ({ candidate, distance: Math.abs((candidate.year ?? year) - year) }))
+ .sort((a, b) => a.distance - b.distance)[0].candidate;
+ const metricValue = graphSampleValue(sample, graphState.metric);
+ const status = row.history.active && row.history.ended === null ? "living" : "past";
+ els.stateGraphInfo.textContent =
+ `S${row.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` +
+ `${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` +
+ `pop ${Math.round(sample.population || 0).toLocaleString()} ` +
+ `cities ${Math.round(sample.cities || 0).toLocaleString()} ` +
+ `loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`;
+}
+
+function clearStateGraphInfo() {
+ if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details";
+ if (graphState.hoverPolityId !== null) {
+ graphState.hoverPolityId = null;
+ if (!running) render();
+ }
}
function formatGraphYear(month) {
@@ -3489,7 +3899,14 @@ function formatSimDate(month) {
function loop() {
const steps = running ? Number(els.speed.value) : 0;
- for (let i = 0; i < steps; i++) sim.step();
+ const loopStart = performance.now();
+ let completedSteps = 0;
+ const stepBudgetMs = 10.5;
+ for (let i = 0; i < steps; i++) {
+ sim.step();
+ completedSteps++;
+ if (running && completedSteps > 0 && performance.now() - loopStart > stepBudgetMs) break;
+ }
if (running || frame % 8 === 0) {
render();
updateStats();
@@ -3741,6 +4158,26 @@ els.viewMode.addEventListener("change", () => {
});
els.canvas.addEventListener("mousemove", showTooltip);
els.canvas.addEventListener("mouseleave", hideTooltip);
+for (const control of [els.historyFilter, els.historySort, els.historyMetric]) {
+ control?.addEventListener("change", () => {
+ renderStateGraph();
+ clearStateGraphInfo();
+ });
+}
+els.toggleActiveStates?.addEventListener("click", () => {
+ graphState.showActive = !graphState.showActive;
+ els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive));
+ renderStateGraph();
+ clearStateGraphInfo();
+});
+els.togglePastStates?.addEventListener("click", () => {
+ graphState.showPast = !graphState.showPast;
+ els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast));
+ renderStateGraph();
+ clearStateGraphInfo();
+});
+els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo);
+els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo);
reset();
loop();
diff --git a/styles.css b/styles.css
index 72e281f..69cc302 100644
--- a/styles.css
+++ b/styles.css
@@ -90,17 +90,67 @@ select {
font-variant-numeric: tabular-nums;
}
-#stateGraph {
- display: block;
- width: 100%;
- flex: 1;
- min-height: 420px;
+.history-controls,
+.history-group-controls {
+ display: grid;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+.history-controls {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.history-controls select,
+.history-group-controls button {
+ min-width: 0;
border: 1px solid var(--line);
border-radius: 6px;
background: #101315;
+ color: var(--text);
+ font-size: 11px;
+ height: 28px;
+}
+
+.history-group-controls {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.history-group-controls button[aria-pressed="false"] {
+ color: var(--muted);
+ background: #171b1d;
+}
+
+#stateGraph {
+ display: block;
+ width: 100%;
+ height: auto;
+ border: 0;
+ background: #101315;
image-rendering: auto;
}
+.state-graph-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #101315;
+}
+
+.state-graph-info {
+ min-height: 18px;
+ margin-top: 7px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.25;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.brand h1 {
margin: 0 0 6px;
font-size: 24px;
@@ -330,7 +380,7 @@ canvas#world {
.history-sidebar {
border-left: 0;
border-top: 1px solid var(--line);
- overflow: visible;
+ overflow: hidden;
}
.sim {
@@ -343,7 +393,7 @@ canvas#world {
height: min(calc(100vw - 20px), 92vh);
}
- #stateGraph {
+ .state-graph-scroll {
height: 420px;
flex: none;
}