Enhance UI layout with history sidebar and state graph; update styles for improved responsiveness

This commit is contained in:
33333-33333 2026-05-12 15:46:07 +09:00
commit 9f49359384
3 changed files with 500 additions and 33 deletions

View file

@ -8,7 +8,7 @@
</head>
<body>
<main class="app">
<aside class="sidebar" aria-label="Simulation controls">
<aside class="sidebar left-sidebar" aria-label="Simulation controls">
<div class="brand">
<h1>Civil Emergence Lab</h1>
<p>Local rules, genealogical cultures, settlement pressure, migration, and collapse.</p>
@ -54,7 +54,6 @@
<option value="ethnicity">Ethnicity</option>
<option value="pheromone">Trade trails</option>
<option value="pressure">Population pressure</option>
<option value="cities">Cities</option>
<option value="polities">Polities</option>
</select>
</label>
@ -62,7 +61,7 @@
<section class="panel stats" aria-live="polite">
<dl>
<div><dt>Year</dt><dd id="year">0</dd></div>
<div><dt>Date</dt><dd id="year">0y 1m</dd></div>
<div><dt>Total population</dt><dd id="population">0</dd></div>
<div><dt>Active groups</dt><dd id="activeGroups">0</dd></div>
<div><dt>Urban population</dt><dd id="urbanPopulation">0</dd></div>
@ -86,6 +85,16 @@
<div class="legend" id="legend"></div>
<div class="tooltip" id="tooltip" hidden></div>
</section>
<aside class="history-sidebar" aria-label="State history">
<section class="panel history-panel">
<div class="history-header">
<h2>State lifespans</h2>
<span id="historyRange"></span>
</div>
<canvas id="stateGraph" width="300" height="720" aria-label="State lifespan graph"></canvas>
</section>
</aside>
</main>
<script src="script.js"></script>

446
script.js
View file

@ -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) {
`<li><span class="lineage-chip" style="background: rgb(${e.color.join(",")})"></span>` +
`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}</li>`
)).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);

View file

@ -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;
}
}