From a9705e7ab17a395fc0bbf0f6e311d74bf55def62 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 13 May 2026 17:28:07 +0900 Subject: [PATCH] less laggy --- index.html | 29 ++- script.js | 741 ++++++++++++++++++++++++++++++++++++++++++----------- styles.css | 64 ++++- 3 files changed, 674 insertions(+), 160 deletions(-) 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; }