separation of script.js
This commit is contained in:
parent
bddec03d22
commit
5d09ad30d7
7 changed files with 1314 additions and 1307 deletions
50
app-state.js
Normal file
50
app-state.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const elementIds = [
|
||||
"world", "toggleRun", "stepOnce", "resetWorld",
|
||||
"speed", "agentCount", "worldSize", "viewMode", "year", "activeGroups", "urbanPopulation",
|
||||
"ethnicities", "cities", "polities", "wars", "routes", "farmingKnowledge", "metallurgyKnowledge",
|
||||
"deaths", "frameCost", "ethnicityList", "legend", "tooltip", "stateGraph", "historyRange",
|
||||
"historyFilter", "historySort", "historyMetric", "toggleActiveStates", "togglePastStates",
|
||||
"stateGraphInfo", "stateGraphTooltip"
|
||||
];
|
||||
const els = Object.fromEntries(elementIds.map(id => [id === "world" ? "canvas" : id, document.getElementById(id)]));
|
||||
els.sim = document.querySelector(".sim");
|
||||
const ctx = els.canvas.getContext("2d", { alpha: false });
|
||||
let sim;
|
||||
let running = true;
|
||||
let lastStatsAt = 0;
|
||||
let lastGraphRenderAt = 0;
|
||||
let lastRenderAt = 0;
|
||||
let lastSimTickAt = 0;
|
||||
let renderDirty = true;
|
||||
let loopTimer = null;
|
||||
let loopScheduled = false;
|
||||
let hoverState = null;
|
||||
let renderImage = null;
|
||||
let renderImageSize = 0;
|
||||
const LoopConfig = Object.freeze({
|
||||
simTickMs: 33,
|
||||
renderMs: 66,
|
||||
stepBudgetMs: 4.5,
|
||||
idlePollMs: 180,
|
||||
hiddenPollMs: 1000
|
||||
});
|
||||
const graphState = {
|
||||
filter: "all",
|
||||
sort: "oldest",
|
||||
metric: "power",
|
||||
showActive: true,
|
||||
showPast: true,
|
||||
hoverPolityId: null,
|
||||
rows: [],
|
||||
markers: [],
|
||||
scale: null
|
||||
};
|
||||
const legendByMode = {
|
||||
terrain: () => terrainInfo.map(t => `<span><i style="background: rgb(${t.color.join(",")})"></i>${t.name}</span>`).join(""),
|
||||
ethnicity: () => "<span>Agent and city colors show lineage. Land remains terrain-colored.</span>",
|
||||
pressure: () => "<span><i style=\"background:#d24943\"></i>High local population pressure</span>",
|
||||
polities: () => "<span>Color = city-centered state. Uncolored cities are independent.</span>",
|
||||
technology: () => "<span><i style=\"background:#5fab5b\"></i>Farming knowledge</span><span><i style=\"background:#caa966\"></i>Metallurgy knowledge</span>",
|
||||
pheromone: () => "<span><i style=\"background:#d8b156\"></i>Pheromone strength</span><span><i style=\"background:#ffd75a\"></i>Formal trade routes</span>",
|
||||
resources: () => "<span><i style=\"background:#6bbc55\"></i>Regenerating local resource stock</span>"
|
||||
};
|
||||
116
boot.js
Normal file
116
boot.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
function requestRender() {
|
||||
renderDirty = true;
|
||||
}
|
||||
function drawFrame(forceStats = false) {
|
||||
render();
|
||||
updateStats(forceStats);
|
||||
renderTooltip();
|
||||
renderDirty = false;
|
||||
lastRenderAt = performance.now();
|
||||
}
|
||||
function scheduleLoop() {
|
||||
if (loopScheduled) return;
|
||||
loopScheduled = true;
|
||||
const delay = document.hidden
|
||||
? LoopConfig.hiddenPollMs
|
||||
: running ? 0 : LoopConfig.idlePollMs;
|
||||
if (delay > 0) {
|
||||
loopTimer = setTimeout(() => {
|
||||
loopTimer = null;
|
||||
requestAnimationFrame(loop);
|
||||
}, delay);
|
||||
} else {
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
}
|
||||
function loop(now = performance.now()) {
|
||||
loopScheduled = false;
|
||||
if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) {
|
||||
lastSimTickAt = now;
|
||||
const steps = Number(els.speed.value);
|
||||
const stepBudget = LoopConfig.stepBudgetMs * Math.max(1, steps);
|
||||
const loopStart = performance.now();
|
||||
let completedSteps = 0;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
sim.step();
|
||||
completedSteps++;
|
||||
if (completedSteps > 0 && performance.now() - loopStart > stepBudget) break;
|
||||
}
|
||||
if (completedSteps > 0) requestRender();
|
||||
}
|
||||
const shouldRender = !document.hidden &&
|
||||
(renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs));
|
||||
if (shouldRender) drawFrame();
|
||||
scheduleLoop();
|
||||
}
|
||||
function reset() {
|
||||
const size = Number(els.worldSize.value);
|
||||
const count = Number(els.agentCount.value);
|
||||
els.canvas.width = size;
|
||||
els.canvas.height = size;
|
||||
renderImage = null;
|
||||
renderImageSize = 0;
|
||||
sim = new Simulation(size, count);
|
||||
setLegend();
|
||||
drawFrame(true);
|
||||
renderStateGraph();
|
||||
}
|
||||
function refreshStateGraph() {
|
||||
renderStateGraph();
|
||||
clearStateGraphInfo();
|
||||
}
|
||||
els.toggleRun.addEventListener("click", () => {
|
||||
running = !running;
|
||||
els.toggleRun.textContent = running ? "Pause" : "Run";
|
||||
lastSimTickAt = 0;
|
||||
if (running && loopTimer) {
|
||||
clearTimeout(loopTimer);
|
||||
loopTimer = null;
|
||||
loopScheduled = false;
|
||||
scheduleLoop();
|
||||
} else if (running) {
|
||||
scheduleLoop();
|
||||
}
|
||||
});
|
||||
els.stepOnce.addEventListener("click", () => {
|
||||
sim.step();
|
||||
requestRender();
|
||||
drawFrame(true);
|
||||
renderStateGraph();
|
||||
});
|
||||
els.resetWorld.addEventListener("click", reset);
|
||||
for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset);
|
||||
els.viewMode.addEventListener("change", () => {
|
||||
setLegend();
|
||||
requestRender();
|
||||
drawFrame();
|
||||
});
|
||||
els.canvas.addEventListener("mousemove", showTooltip);
|
||||
els.canvas.addEventListener("mouseleave", hideTooltip);
|
||||
for (const control of [els.historyFilter, els.historySort, els.historyMetric]) {
|
||||
control?.addEventListener("change", refreshStateGraph);
|
||||
}
|
||||
els.toggleActiveStates?.addEventListener("click", () => {
|
||||
graphState.showActive = !graphState.showActive;
|
||||
els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive));
|
||||
refreshStateGraph();
|
||||
});
|
||||
els.togglePastStates?.addEventListener("click", () => {
|
||||
graphState.showPast = !graphState.showPast;
|
||||
els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast));
|
||||
refreshStateGraph();
|
||||
});
|
||||
els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo);
|
||||
els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
lastSimTickAt = 0;
|
||||
requestRender();
|
||||
if (!document.hidden && loopTimer) {
|
||||
clearTimeout(loopTimer);
|
||||
loopTimer = null;
|
||||
loopScheduled = false;
|
||||
scheduleLoop();
|
||||
}
|
||||
});
|
||||
reset();
|
||||
loop();
|
||||
110
config.js
Normal file
110
config.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
const Terrain = Object.freeze({
|
||||
PLAINS: 0,
|
||||
FOREST: 1,
|
||||
MOUNTAIN: 2,
|
||||
WATER: 3,
|
||||
DESERT: 4
|
||||
});
|
||||
const terrainInfo = [
|
||||
{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 },
|
||||
{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 },
|
||||
{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 },
|
||||
{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 },
|
||||
{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 }
|
||||
];
|
||||
const WEEKS_PER_MONTH = 4;
|
||||
const MONTHS_PER_YEAR = 12;
|
||||
const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH;
|
||||
const SimConfig = Object.freeze({
|
||||
population: Object.freeze({
|
||||
maxAgentsFloor: 8000,
|
||||
maxAgentsScale: 1.15,
|
||||
softCapStart: 0.85,
|
||||
hardCapScale: 1.35,
|
||||
offspringCrowdingPenalty: 1.8,
|
||||
minOffspringAcceptance: 0.035,
|
||||
maxOffspringPerStep: 360,
|
||||
carryingCapacityBase: 2.4,
|
||||
carryingCapacityFertility: 7.8,
|
||||
carryingCapacityMineral: 1.8,
|
||||
carryingCapacityFarmland: 5.5,
|
||||
pressurePenaltyBase: 0.2,
|
||||
pressureReproductionPenalty: 0.16
|
||||
}),
|
||||
city: Object.freeze({
|
||||
maxCities: 300
|
||||
}),
|
||||
render: Object.freeze({
|
||||
graphThrottleMs: 1400,
|
||||
statsThrottleMs: 900,
|
||||
historyWindowYears: 2000,
|
||||
historySamplePaddingYears: 240
|
||||
}),
|
||||
technology: Object.freeze({
|
||||
cityDiffusion: 0.0022,
|
||||
tradeDiffusion: 0.0032,
|
||||
cityInnovation: 0.00045
|
||||
}),
|
||||
polity: Object.freeze({
|
||||
minimumPerCapitaFood: 0.06,
|
||||
logisticsDistance: 34
|
||||
}),
|
||||
polityAccess: Object.freeze({
|
||||
enabled: true,
|
||||
maxSearchDepth: 8,
|
||||
indirectPenalty: 0.012,
|
||||
perHopPenalty: 0.006,
|
||||
noAccessPenalty: 0.045,
|
||||
lowLoyaltyTransitPenalty: 0.012,
|
||||
foreignTransitPenalty: 0.008
|
||||
}),
|
||||
culture: Object.freeze({
|
||||
spreadRadius: 3,
|
||||
cityWeight: 0.035,
|
||||
routeWeight: 1.35,
|
||||
minimumInfluence: 0.18
|
||||
}),
|
||||
route: Object.freeze({
|
||||
maxRouteLength: 42,
|
||||
pheromoneDecay: 0.996,
|
||||
pheromoneDiffusion: 0.045,
|
||||
maxPheromone: 18,
|
||||
routePheromoneBuild: 0.18,
|
||||
routePheromoneMaintain: 0.12,
|
||||
routeUpkeepPerTile: 0.018,
|
||||
routeWeakThreshold: 8,
|
||||
routeUnsupportedDecay: 7,
|
||||
routeMaintainedBoost: 10
|
||||
}),
|
||||
disaster: Object.freeze({
|
||||
damageScale: 1.5,
|
||||
majorLossRate: 0.05,
|
||||
checkIntervalYears: 8,
|
||||
baseChance: 0.76,
|
||||
minRadius: 6,
|
||||
maxRadius: 34,
|
||||
rareLargeChance: 0.10,
|
||||
largeRadiusMin: 28,
|
||||
largeRadiusMax: 54,
|
||||
minIntensity: 0.25,
|
||||
maxIntensity: 0.95,
|
||||
visualDurationYears: 18,
|
||||
maxActiveVisuals: 12,
|
||||
maxHistory: 160
|
||||
}),
|
||||
frontierWave: Object.freeze({
|
||||
enabledAfterYears: 300,
|
||||
intervalYears: 80,
|
||||
chance: 0.45,
|
||||
minAgents: 60,
|
||||
maxAgents: 140,
|
||||
maxPopulationRatio: 1.28,
|
||||
edgeBand: 4,
|
||||
mutation: 0.08,
|
||||
minResources: 12,
|
||||
maxResources: 28
|
||||
})
|
||||
});
|
||||
function years(value) {
|
||||
return value * WEEKS_PER_YEAR;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -96,8 +96,6 @@
|
|||
<option value="ended">Past</option>
|
||||
</select>
|
||||
<select id="historySort" aria-label="State graph sort">
|
||||
<option value="important">Important</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="strongest">Strongest</option>
|
||||
<option value="largest">Largest</option>
|
||||
|
|
@ -122,6 +120,11 @@
|
|||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="script.js"></script>
|
||||
<script src="config.js"></script>
|
||||
<script src="utils.js"></script>
|
||||
<script src="app-state.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="render.js"></script>
|
||||
<script src="boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
897
render.js
Normal file
897
render.js
Normal file
|
|
@ -0,0 +1,897 @@
|
|||
function render() {
|
||||
const start = performance.now();
|
||||
const w = sim.world;
|
||||
const size = w.size;
|
||||
if (!renderImage || renderImageSize !== size) {
|
||||
renderImage = ctx.createImageData(size, size);
|
||||
renderImageSize = size;
|
||||
}
|
||||
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 = cityPolity(city);
|
||||
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;
|
||||
if (mode === "resources") {
|
||||
const v = clamp(w.resource[i] / 30, 0, 1);
|
||||
color = mix([28, 36, 40], [107, 188, 85], v);
|
||||
} else if (mode === "ethnicity") {
|
||||
color = terrainInfo[w.terrain[i]].color;
|
||||
if (w.city[i] >= 0) {
|
||||
const city = sim.getCityById(w.city[i]);
|
||||
if (city) color = mix(color, cityMajorityColor(city), 0.72);
|
||||
}
|
||||
} else if (mode === "pressure") {
|
||||
const v = clamp(w.pressure[i] / 12, 0, 1);
|
||||
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
|
||||
} else if (mode === "polities") {
|
||||
color = terrainInfo[w.terrain[i]].color;
|
||||
if (w.city[i] >= 0) {
|
||||
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);
|
||||
} else if (mode === "pheromone") {
|
||||
const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
|
||||
color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v);
|
||||
} else {
|
||||
color = terrainInfo[w.terrain[i]].color;
|
||||
}
|
||||
const p = i * 4;
|
||||
data[p] = color[0];
|
||||
data[p + 1] = color[1];
|
||||
data[p + 2] = color[2];
|
||||
data[p + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
renderDisasters();
|
||||
drawTradeLinks();
|
||||
drawAgentsAndCities(mode);
|
||||
drawGraphPolityHighlight();
|
||||
maybeRenderStateGraph();
|
||||
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
|
||||
}
|
||||
function renderDisasters() {
|
||||
if (!sim?.disasters?.length) return;
|
||||
sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires);
|
||||
if (!sim.disasters.length) return;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
for (const disaster of sim.disasters) {
|
||||
const duration = Math.max(1, disaster.expires - disaster.year);
|
||||
const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1);
|
||||
const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22);
|
||||
if (alpha <= 0.005) continue;
|
||||
const innerRadius = Math.max(1, disaster.radius * 0.32);
|
||||
const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius);
|
||||
gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
|
||||
gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
|
||||
gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
|
||||
ctx.fillStyle = gradient;
|
||||
drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius);
|
||||
ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
|
||||
ctx.lineWidth = 1;
|
||||
strokeCircle(ctx, disaster.x, disaster.y, disaster.radius);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function rgb(color, boost = 0) {
|
||||
return `rgb(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)})`;
|
||||
}
|
||||
function rgba(color, alpha, boost = 0) {
|
||||
return `rgba(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)}, ${alpha})`;
|
||||
}
|
||||
function fillSquare(g, x, y, radius) {
|
||||
g.fillRect(x - radius, y - radius, radius * 2 + 1, radius * 2 + 1);
|
||||
}
|
||||
function strokeLine(g, x1, y1, x2, y2) {
|
||||
g.beginPath();
|
||||
g.moveTo(x1, y1);
|
||||
g.lineTo(x2, y2);
|
||||
g.stroke();
|
||||
}
|
||||
function strokeCircle(g, x, y, r) {
|
||||
g.beginPath();
|
||||
g.arc(x, y, r, 0, Math.PI * 2);
|
||||
g.stroke();
|
||||
}
|
||||
function drawTradeLinks() {
|
||||
const w = sim.world;
|
||||
ctx.save();
|
||||
const currentLinkTiles = new Set();
|
||||
for (const link of sim.tradeLinks) {
|
||||
for (const tile of link.path || []) currentLinkTiles.add(tile);
|
||||
}
|
||||
ctx.globalAlpha = 0.24;
|
||||
ctx.fillStyle = "#d9b650";
|
||||
for (const i of sim.activeTradeRouteTiles || []) {
|
||||
if (currentLinkTiles.has(i)) continue;
|
||||
ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
|
||||
}
|
||||
if (!sim.tradeLinks.length) {
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
for (const link of sim.tradeLinks) {
|
||||
ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42);
|
||||
ctx.fillStyle = "#ffd75a";
|
||||
for (const tile of link.path || []) {
|
||||
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
function drawAgentsAndCities(mode) {
|
||||
ctx.save();
|
||||
if (mode !== "ethnicity") {
|
||||
for (const city of sim.cities) {
|
||||
const radius = cityRenderRadius(city);
|
||||
const color = cityDisplayColor(city, mode);
|
||||
ctx.fillStyle = rgba(color, 0.72);
|
||||
ctx.globalAlpha = 0.88;
|
||||
fillSquare(ctx, city.x, city.y, radius);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (mode === "technology") {
|
||||
for (const a of sim.agents) {
|
||||
sim.ensureAgentTech(a);
|
||||
const farming = techLevel(a, "farming");
|
||||
const metallurgy = techLevel(a, "metallurgy");
|
||||
const tech = clamp(Math.max(farming, metallurgy), 0, 1);
|
||||
if (tech <= 0.01) continue;
|
||||
const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
|
||||
ctx.fillStyle = rgba(color, clamp(0.32 + tech * 0.68, 0.32, 1));
|
||||
ctx.fillRect(a.x, a.y, 1, 1);
|
||||
}
|
||||
} else if (mode === "ethnicity") {
|
||||
for (const a of sim.agents) {
|
||||
const e = sim.ethnicities.get(a.ethnicity);
|
||||
if (!e) continue;
|
||||
ctx.fillStyle = rgb(e.color);
|
||||
ctx.fillRect(a.x, a.y, 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);
|
||||
}
|
||||
}
|
||||
for (const city of sim.cities) {
|
||||
if (mode === "ethnicity") {
|
||||
const radius = cityRenderRadius(city);
|
||||
const color = cityDisplayColor(city, mode);
|
||||
ctx.globalAlpha = 0.90;
|
||||
ctx.fillStyle = rgba(color, 0.78);
|
||||
fillSquare(ctx, city.x, city.y, radius);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
const color = cityDisplayColor(city, mode);
|
||||
ctx.fillStyle = rgb(color, 55);
|
||||
ctx.fillRect(city.x, city.y, 1, 1);
|
||||
}
|
||||
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(color, 72);
|
||||
ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
|
||||
ctx.globalAlpha = 0.32;
|
||||
ctx.fillStyle = rgb(color);
|
||||
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);
|
||||
}
|
||||
function cityMajorityColor(city) {
|
||||
const id = dominantComposition(city.ethnicityComposition);
|
||||
return sim.ethnicities.get(id)?.color || [242, 215, 134];
|
||||
}
|
||||
function cityPolity(city) {
|
||||
return city?.polityId != null ? sim.getPolityById(city.polityId) : null;
|
||||
}
|
||||
function cityDisplayColor(city, mode) {
|
||||
if (mode === "polities") {
|
||||
const polity = cityPolity(city);
|
||||
if (polity) return polity.color;
|
||||
return [218, 205, 154];
|
||||
}
|
||||
return cityMajorityColor(city);
|
||||
}
|
||||
function techLevel(holder, key) {
|
||||
return holder?.tech?.[key] || 0;
|
||||
}
|
||||
function knowledgeLevel(city, key) {
|
||||
return city?.knowledge?.[key] || 0;
|
||||
}
|
||||
function showTooltip(event) {
|
||||
const rect = els.canvas.getBoundingClientRect();
|
||||
const simRect = els.sim.getBoundingClientRect();
|
||||
const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
|
||||
const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
|
||||
hoverState = {
|
||||
x,
|
||||
y,
|
||||
left: event.clientX - simRect.left + 16,
|
||||
top: event.clientY - simRect.top + 16,
|
||||
width: simRect.width,
|
||||
height: simRect.height
|
||||
};
|
||||
renderTooltip();
|
||||
}
|
||||
function tooltipRow(label, value, show = true) {
|
||||
return show ? `<span><b>${label}</b><em>${value}</em></span>` : "";
|
||||
}
|
||||
function tooltipSection(title, items) {
|
||||
const body = items.filter(Boolean).join("");
|
||||
return body ? `<section><h3>${title}</h3>${body}</section>` : "";
|
||||
}
|
||||
function renderTooltip() {
|
||||
if (!hoverState) return;
|
||||
const x = hoverState.x;
|
||||
const y = hoverState.y;
|
||||
if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
const w = sim.world;
|
||||
const i = w.idx(x, y);
|
||||
const agent = findAgentAt(x, y);
|
||||
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);
|
||||
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
|
||||
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
|
||||
const polity = cityPolity(city);
|
||||
const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
|
||||
els.tooltip.innerHTML = `
|
||||
<strong>${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name}</strong>
|
||||
${tooltipSection("Tile", [
|
||||
tooltipRow("Position", `${x}, ${y}`),
|
||||
tooltipRow("Terrain", terrain.name),
|
||||
tooltipRow("Resources", w.resource[i].toFixed(1)),
|
||||
tooltipRow("Fertility", w.fertility[i].toFixed(2)),
|
||||
tooltipRow("Minerals", w.mineral[i].toFixed(2)),
|
||||
tooltipRow("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`),
|
||||
tooltipRow("Water", waterInfluence),
|
||||
tooltipRow("Pressure", w.pressure[i].toFixed(0)),
|
||||
tooltipRow("Pheromone", w.pheromone[i].toFixed(1)),
|
||||
tooltipRow("Route", w.tradeRoute[i], w.tradeRoute[i])
|
||||
])}
|
||||
${tooltipSection("City & State", [
|
||||
tooltipRow("Population", city?.population.toLocaleString(), city),
|
||||
tooltipRow("Food stock", city?.storedResources.toFixed(1), city),
|
||||
tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city),
|
||||
tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city),
|
||||
tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city),
|
||||
tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity),
|
||||
city ? cityEthnicityPie(city) : "",
|
||||
tooltipRow("State", polity ? `#${polity.id}` : "Independent", city),
|
||||
tooltipRow("Loyalty", city?.loyalty.toFixed(2), city),
|
||||
tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity),
|
||||
tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity),
|
||||
tooltipRow("Treasury", polity?.treasury.toFixed(1), polity),
|
||||
tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity)
|
||||
])}
|
||||
${tooltipSection("Culture & Agent", [
|
||||
tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`),
|
||||
tooltipRow("Agent ethnicity", `E${agent?.ethnicity}`, agent),
|
||||
tooltipRow("Lineage pop", ethnicity?.population, ethnicity),
|
||||
tooltipRow("Climate pref", ethnicity ? `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : "", ethnicity),
|
||||
tooltipRow("Climate mismatch", mismatch.toFixed(2), agent),
|
||||
tooltipRow("Sedentary", agent ? agent.traits.sedentary.toFixed(2) : "", agent),
|
||||
tooltipRow("Ethnocentrism", agent ? agent.traits.ethnocentrism.toFixed(2) : "", agent),
|
||||
tooltipRow("Farming", agent ? `${techLevel(agent, "farming").toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : "", agent),
|
||||
tooltipRow("Metallurgy", techLevel(agent, "metallurgy").toFixed(3), agent),
|
||||
tooltipRow("Stored", agent?.resources.toFixed(1), agent)
|
||||
])}
|
||||
`;
|
||||
els.tooltip.hidden = false;
|
||||
const margin = 8;
|
||||
const width = els.tooltip.offsetWidth;
|
||||
const height = els.tooltip.offsetHeight;
|
||||
let left = hoverState.left;
|
||||
let top = hoverState.top;
|
||||
if (left + width + margin > hoverState.width) left = hoverState.left - width - 32;
|
||||
if (top + height + margin > hoverState.height) top = hoverState.height - height - margin;
|
||||
els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`;
|
||||
els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`;
|
||||
}
|
||||
function cityEthnicityPie(city) {
|
||||
if (!city?.ethnicityComposition?.size) return "";
|
||||
const entries = [...city.ethnicityComposition]
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const total = entries.reduce((sum, [, count]) => sum + count, 0);
|
||||
if (total <= 0) return "";
|
||||
const top = entries.slice(0, 5);
|
||||
const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0);
|
||||
const slices = other > 0 ? [...top, [0, other]] : top;
|
||||
let cursor = 0;
|
||||
const gradient = slices.map(([id, count]) => {
|
||||
const start = cursor / total * 100;
|
||||
cursor += count;
|
||||
const end = cursor / total * 100;
|
||||
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
||||
const color = ethnicity?.color || [122, 130, 126];
|
||||
return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
|
||||
}).join(", ");
|
||||
const labels = slices.map(([id, count]) => {
|
||||
const ethnicity = id ? sim.ethnicities.get(id) : null;
|
||||
const color = ethnicity?.color || [122, 130, 126];
|
||||
const label = id ? `E${id}` : "Other";
|
||||
return `<span class="ethnicity-pie-key"><i style="background: rgb(${color.join(",")})"></i>${label} ${Math.round(count / total * 100)}%</span>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="ethnicity-pie-wrap">
|
||||
<div class="ethnicity-pie" style="background: conic-gradient(${gradient})"></div>
|
||||
<div class="ethnicity-pie-labels">${labels}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
function hideTooltip() {
|
||||
hoverState = null;
|
||||
els.tooltip.hidden = true;
|
||||
}
|
||||
function findAgentAt(x, y) {
|
||||
const directIndex = sim.world.idx(x, y);
|
||||
return sim.tileAgents.get(directIndex)?.[0] || null;
|
||||
}
|
||||
function waterInfluenceAt(world, x, y) {
|
||||
let score = 0;
|
||||
for (let dy = -3; dy <= 3; dy++) {
|
||||
for (let dx = -3; dx <= 3; dx++) {
|
||||
const d = Math.abs(dx) + Math.abs(dy);
|
||||
if (!d || d > 3) continue;
|
||||
const tx = x + dx;
|
||||
const ty = y + dy;
|
||||
if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue;
|
||||
if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4;
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
function updateStats(force = false) {
|
||||
const now = performance.now();
|
||||
if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return;
|
||||
lastStatsAt = now;
|
||||
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
|
||||
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
|
||||
let farmingTotal = 0;
|
||||
let metallurgyTotal = 0;
|
||||
let farmingHolders = 0;
|
||||
let metallurgyHolders = 0;
|
||||
for (const a of sim.agents) {
|
||||
sim.ensureAgentTech(a);
|
||||
farmingTotal += techLevel(a, "farming");
|
||||
metallurgyTotal += techLevel(a, "metallurgy");
|
||||
if (techLevel(a, "farming") > 0.02) farmingHolders++;
|
||||
if (techLevel(a, "metallurgy") > 0.02) metallurgyHolders++;
|
||||
}
|
||||
const agentCount = Math.max(1, sim.agents.length);
|
||||
const stats = {
|
||||
year: formatSimDate(sim.year),
|
||||
activeGroups: sim.agents.length.toLocaleString(),
|
||||
urbanPopulation: Math.floor(urbanPopulation).toLocaleString(),
|
||||
ethnicities: livingEthnicities.length.toLocaleString(),
|
||||
cities: sim.cities.length.toLocaleString(),
|
||||
polities: sim.polities.length.toLocaleString(),
|
||||
wars: sim.wars.length.toLocaleString(),
|
||||
routes: sim.tradeLinks.length.toLocaleString(),
|
||||
farmingKnowledge: `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`,
|
||||
metallurgyKnowledge: `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`,
|
||||
deaths: sim.deaths.toLocaleString()
|
||||
};
|
||||
for (const [id, value] of Object.entries(stats)) {
|
||||
if (els[id]) els[id].textContent = value;
|
||||
}
|
||||
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
|
||||
els.ethnicityList.innerHTML = top.map(e => (
|
||||
`<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() {
|
||||
const mode = els.viewMode.value;
|
||||
els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)();
|
||||
}
|
||||
function maybeRenderStateGraph() {
|
||||
const now = performance.now();
|
||||
if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return;
|
||||
lastGraphRenderAt = now;
|
||||
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;
|
||||
}
|
||||
const graphMetrics = {
|
||||
power: {
|
||||
value: sampleGraphPower,
|
||||
format: value => value.toFixed(1)
|
||||
},
|
||||
population: {
|
||||
value: sample => sample?.population || 0,
|
||||
format: value => Math.round(value).toLocaleString()
|
||||
},
|
||||
cities: {
|
||||
value: sample => sample?.cities || 0,
|
||||
format: value => Math.round(value).toLocaleString()
|
||||
},
|
||||
loyalty: {
|
||||
value: sample => sample?.avgLoyalty ?? 0.5,
|
||||
format: value => `${Math.round(clamp(value, 0, 1) * 100)}%`
|
||||
}
|
||||
};
|
||||
function graphSampleValue(sample, metric) {
|
||||
return (graphMetrics[metric] || graphMetrics.power).value(sample);
|
||||
}
|
||||
function formatGraphValue(value, metric) {
|
||||
return (graphMetrics[metric] || graphMetrics.power).format(value);
|
||||
}
|
||||
function renderStateGraph() {
|
||||
const canvas = els.stateGraph;
|
||||
if (!canvas || !sim) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
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 => ({
|
||||
...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)
|
||||
.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);
|
||||
}
|
||||
return ((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.markers = [];
|
||||
graphState.scale = null;
|
||||
g.fillStyle = "#7f8984";
|
||||
g.font = "12px ui-sans-serif, system-ui, sans-serif";
|
||||
g.fillText("No state history yet", 16, 28);
|
||||
return;
|
||||
}
|
||||
let minYear = historyWindowStart;
|
||||
let maxYear = historyWindowEnd;
|
||||
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 plotWidth = Math.max(1, w - paddingLeft - paddingRight);
|
||||
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.markers = [];
|
||||
graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear };
|
||||
const axisY = h - paddingBottom + 4;
|
||||
g.strokeStyle = "rgba(168, 177, 170, 0.22)";
|
||||
g.lineWidth = 1;
|
||||
strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY);
|
||||
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);
|
||||
const topEvents = (sim.graphEvents || [])
|
||||
.filter(event => event.importance >= 2)
|
||||
.filter(event => event.year >= minYear && event.year <= maxYear)
|
||||
.filter(event => graphEventTypes[event.type]);
|
||||
const topCollisions = new Map();
|
||||
for (const event of topEvents) {
|
||||
const x = yearToX(event.year);
|
||||
const bucket = Math.round(x / 7);
|
||||
const count = topCollisions.get(bucket) || 0;
|
||||
topCollisions.set(bucket, count + 1);
|
||||
const y = Math.max(7, paddingTop * 0.5 + ([-3, 0, 3][count % 3]));
|
||||
const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
|
||||
const graphEvent = graphEventTypes[event.type];
|
||||
g.fillStyle = graphEvent.color;
|
||||
graphEvent.draw(g, x, y, r);
|
||||
graphState.markers.push({ row: null, event, x, y, radius: r + 3 });
|
||||
}
|
||||
rows.forEach(row => {
|
||||
const alpha = row.type === "group" ? 0.16 : 0.12;
|
||||
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, ${alpha})`;
|
||||
g.lineWidth = 1;
|
||||
strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y);
|
||||
});
|
||||
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)})`;
|
||||
strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
|
||||
g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`;
|
||||
g.lineWidth = Math.max(2, thickness * 0.62);
|
||||
strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
|
||||
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);
|
||||
}
|
||||
});
|
||||
rows.forEach(row => {
|
||||
if (row.type !== "state") return;
|
||||
const events = (row.history.events || [])
|
||||
.filter(event => event.importance >= 2)
|
||||
.filter(event => event.year >= minYear && event.year <= maxYear)
|
||||
.filter(event => graphEventTypes[event.type]);
|
||||
const collisions = new Map();
|
||||
for (const event of events) {
|
||||
const x = yearToX(event.year);
|
||||
const bucket = Math.round(x / 7);
|
||||
const count = collisions.get(bucket) || 0;
|
||||
collisions.set(bucket, count + 1);
|
||||
const y = row.y + ([-4, 0, 4][count % 3]);
|
||||
const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
|
||||
const graphEvent = graphEventTypes[event.type];
|
||||
g.fillStyle = graphEvent.color;
|
||||
graphEvent.draw(g, x, y, r);
|
||||
graphState.markers.push({ row, event, x, y, radius: r + 3 });
|
||||
}
|
||||
});
|
||||
}
|
||||
function drawGraphCircle(g, x, y, r) {
|
||||
g.beginPath();
|
||||
g.arc(x, y, r, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
}
|
||||
function drawGraphPolygon(g, points) {
|
||||
g.beginPath();
|
||||
g.moveTo(points[0][0], points[0][1]);
|
||||
for (let i = 1; i < points.length; i++) g.lineTo(points[i][0], points[i][1]);
|
||||
g.closePath();
|
||||
g.fill();
|
||||
}
|
||||
function drawGraphDiamond(g, x, y, r) {
|
||||
drawGraphPolygon(g, [[x, y - r], [x + r, y], [x, y + r], [x - r, y]]);
|
||||
}
|
||||
function drawGraphTriangleUp(g, x, y, r) {
|
||||
drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]);
|
||||
}
|
||||
function drawGraphTriangleDown(g, x, y, r) {
|
||||
drawGraphPolygon(g, [[x, y + r], [x + r, y - r], [x - r, y - r]]);
|
||||
}
|
||||
const graphEventTypes = {
|
||||
capitalShift: {
|
||||
label: "capital shift",
|
||||
color: "rgba(227, 179, 65, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`
|
||||
},
|
||||
disaster: {
|
||||
label: "major disaster",
|
||||
color: "rgba(211, 95, 84, 0.95)",
|
||||
draw: drawGraphCircle,
|
||||
summary: data => `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`
|
||||
},
|
||||
famine: {
|
||||
label: "famine",
|
||||
color: "rgba(227, 128, 65, 0.90)",
|
||||
draw: drawGraphTriangleDown,
|
||||
summary: data => `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`
|
||||
},
|
||||
rebellion: {
|
||||
label: "rebellion",
|
||||
color: "rgba(236, 126, 111, 0.95)",
|
||||
draw: drawGraphTriangleUp,
|
||||
summary: data => data.cityCount > 1
|
||||
? `${data.cityCount} cities, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}`
|
||||
: `city #${data.cityId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}`
|
||||
},
|
||||
newEthnicity: {
|
||||
label: "new ethnicity",
|
||||
color: "rgba(105, 181, 120, 0.95)",
|
||||
draw: drawGraphDiamond,
|
||||
summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier groups`
|
||||
}
|
||||
};
|
||||
function graphEventLabel(event) {
|
||||
return graphEventTypes[event.type]?.label || event.type;
|
||||
}
|
||||
function graphEventSummary(event) {
|
||||
return graphEventTypes[event.type]?.summary(event.data || {}) || "";
|
||||
}
|
||||
function showStateGraphMarkerTooltip(marker, pointerX, pointerY) {
|
||||
if (!els.stateGraphTooltip) return;
|
||||
const title = marker.row
|
||||
? `S${marker.row.history.id} ${graphEventLabel(marker.event)}`
|
||||
: graphEventLabel(marker.event);
|
||||
els.stateGraphTooltip.innerHTML = `
|
||||
<strong>${title}</strong>
|
||||
<span>${formatGraphYear(marker.event.year)}</span>
|
||||
<span>${graphEventSummary(marker.event)}</span>
|
||||
`;
|
||||
els.stateGraphTooltip.hidden = false;
|
||||
const scroll = els.stateGraph.parentElement;
|
||||
const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8);
|
||||
const left = clamp(pointerX + 10, 8, maxLeft);
|
||||
const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10);
|
||||
els.stateGraphTooltip.style.left = `${left}px`;
|
||||
els.stateGraphTooltip.style.top = `${top}px`;
|
||||
}
|
||||
function hideStateGraphMarkerTooltip() {
|
||||
if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true;
|
||||
}
|
||||
function updateStateGraphInfo(event) {
|
||||
if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return;
|
||||
const y = event.offsetY;
|
||||
const x = event.offsetX;
|
||||
const marker = (graphState.markers || [])
|
||||
.map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) }))
|
||||
.filter(item => item.distance <= item.candidate.radius);
|
||||
const nearestMarker = bestBy(marker, item => -item.distance)?.candidate;
|
||||
if (nearestMarker) {
|
||||
if (!nearestMarker.row) {
|
||||
els.stateGraphInfo.textContent = `${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
|
||||
showStateGraphMarkerTooltip(nearestMarker, x, y);
|
||||
if (graphState.hoverPolityId !== null) {
|
||||
graphState.hoverPolityId = null;
|
||||
if (!running) render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const status = nearestMarker.row.history.active && nearestMarker.row.history.ended === null ? "living" : "past";
|
||||
els.stateGraphInfo.textContent = `S${nearestMarker.row.history.id} ${status} ${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
|
||||
showStateGraphMarkerTooltip(nearestMarker, x, y);
|
||||
if (graphState.hoverPolityId !== nearestMarker.row.history.id) {
|
||||
graphState.hoverPolityId = nearestMarker.row.history.id;
|
||||
if (!running) render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const row = graphState.rows
|
||||
.map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) }))
|
||||
.filter(item => item.distance <= item.candidate.height * 0.5);
|
||||
const nearestRow = bestBy(row, item => -item.distance)?.candidate;
|
||||
if (!nearestRow) {
|
||||
els.stateGraphInfo.textContent = "Hover a row for details";
|
||||
hideStateGraphMarkerTooltip();
|
||||
if (graphState.hoverPolityId !== null) {
|
||||
graphState.hoverPolityId = null;
|
||||
if (!running) render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (graphState.hoverPolityId !== nearestRow.history.id) {
|
||||
graphState.hoverPolityId = nearestRow.history.id;
|
||||
if (!running) render();
|
||||
}
|
||||
hideStateGraphMarkerTooltip();
|
||||
const year = graphState.scale.xToYear(x);
|
||||
const samples = nearestRow.samples.length ? nearestRow.samples : [{
|
||||
year,
|
||||
population: 0,
|
||||
cities: nearestRow.peakCities,
|
||||
avgLoyalty: 0.5,
|
||||
power: nearestRow.peakPower
|
||||
}];
|
||||
const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year));
|
||||
const metricValue = graphSampleValue(sample, graphState.metric);
|
||||
const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past";
|
||||
els.stateGraphInfo.textContent =
|
||||
`S${nearestRow.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";
|
||||
hideStateGraphMarkerTooltip();
|
||||
if (graphState.hoverPolityId !== null) {
|
||||
graphState.hoverPolityId = null;
|
||||
if (!running) render();
|
||||
}
|
||||
}
|
||||
function formatGraphYear(week) {
|
||||
return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`;
|
||||
}
|
||||
function formatSimDate(week) {
|
||||
const year = Math.floor(week / WEEKS_PER_YEAR);
|
||||
const weekOfYear = week % WEEKS_PER_YEAR;
|
||||
const monthOfYear = Math.floor(weekOfYear / WEEKS_PER_MONTH) + 1;
|
||||
const weekOfMonth = weekOfYear % WEEKS_PER_MONTH + 1;
|
||||
return `${year.toLocaleString()}y ${monthOfYear}m ${weekOfMonth}w`;
|
||||
}
|
||||
116
utils.js
Normal file
116
utils.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
function clamp(v, min, max) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
function bestBy(items, score) {
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const item of items) {
|
||||
const value = score(item);
|
||||
if (value > bestScore) {
|
||||
best = item;
|
||||
bestScore = value;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function smoothstep(t) {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
const traitFields = [
|
||||
["mobility", traits => traits.mobility, 0.02, 1, 1, 1],
|
||||
["resourceAttraction", traits => traits.resourceAttraction, 0.05, 1.2, 1, 1],
|
||||
["assimilation", traits => traits.assimilation, 0, 0.75, 1, 1],
|
||||
["ethnocentrism", traits => traits.ethnocentrism, 0, 1.2, 1, 1],
|
||||
["reproductionThreshold", traits => traits.reproductionThreshold, 12, 48, 16, 1 / 48],
|
||||
["sedentary", traits => traits.sedentary, 0, 1, 1, 1]
|
||||
];
|
||||
function mutateTraits(traits, rng, amount) {
|
||||
return Object.fromEntries(traitFields.map(([key, get, min, max, mutationScale]) => [
|
||||
key,
|
||||
clamp(get(traits) + rng.range(-amount * mutationScale, amount * mutationScale), min, max)
|
||||
]));
|
||||
}
|
||||
function blendTraits(a, b, t) {
|
||||
return Object.fromEntries(traitFields.map(([key, get]) => [key, lerp(get(a), get(b), t)]));
|
||||
}
|
||||
function emptyTraitSums() {
|
||||
return Object.fromEntries(traitFields.map(([key]) => [key, 0]));
|
||||
}
|
||||
function addTraits(sum, traits) {
|
||||
for (const [key, get, , , , sumScale] of traitFields) sum[key] += get(traits) * sumScale;
|
||||
}
|
||||
function averageTraits(sum, count) {
|
||||
return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale]));
|
||||
}
|
||||
function compositionTotal(composition) {
|
||||
return [...composition.values()].reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
function addBirthsToComposition(composition, births, rng = null) {
|
||||
const entries = [...composition.entries()];
|
||||
const total = entries.reduce((sum, [, value]) => sum + value, 0);
|
||||
if (!total || births <= 0) return;
|
||||
|
||||
for (let n = 0; n < births; n++) {
|
||||
let r = (rng ? rng.next() : Math.random()) * total;
|
||||
for (const [id, count] of entries) {
|
||||
r -= count;
|
||||
if (r <= 0) {
|
||||
composition.set(id, (composition.get(id) || 0) + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function removeFromComposition(composition, loss) {
|
||||
let remaining = loss;
|
||||
const total = compositionTotal(composition);
|
||||
if (!total) return;
|
||||
for (const [id, count] of [...composition]) {
|
||||
const removed = Math.min(count, Math.ceil(loss * (count / total)));
|
||||
composition.set(id, Math.max(0, count - removed));
|
||||
remaining -= removed;
|
||||
if (composition.get(id) <= 0) composition.delete(id);
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
}
|
||||
function dominantComposition(composition) {
|
||||
let bestId = null;
|
||||
let bestCount = 0;
|
||||
for (const [id, count] of composition) {
|
||||
if (count > bestCount) {
|
||||
bestId = id;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return bestId;
|
||||
}
|
||||
function traitDistance(a, b) {
|
||||
return traitFields.reduce((sum, [, get, , , , distanceScale]) => sum + Math.abs(get(a) - get(b)) * distanceScale, 0);
|
||||
}
|
||||
function hslToRgb(h, s, l) {
|
||||
const hue = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [
|
||||
Math.round(hue(p, q, h + 1 / 3) * 255),
|
||||
Math.round(hue(p, q, h) * 255),
|
||||
Math.round(hue(p, q, h - 1 / 3) * 255)
|
||||
];
|
||||
}
|
||||
function mix(a, b, t) {
|
||||
return [
|
||||
Math.round(lerp(a[0], b[0], t)),
|
||||
Math.round(lerp(a[1], b[1], t)),
|
||||
Math.round(lerp(a[2], b[2], t))
|
||||
];
|
||||
}
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue