1/**2 * Stats dashboard — the viewer's default tab. Accumulates MetricsFrames3 * (full history in memory: 10k ticks / SLOW_TICK_INTERVAL = 1k frames) and4 * renders five lenses of canvas charts (see viz/charts.ts for the drawing5 * conventions). All series names may be user-provided script names — they6 * only ever reach the DOM via textContent.7 */8import type { MetricsFrame, Profession, TribeMetrics } from '@tribes/protocol';9import { PROFESSIONS } from '@tribes/protocol';10import { h, clear } from '../dom';11import {12 lineChart,13 stackedArea,14 stackedBarsH,15 sparkline,16 legendRow,17 fmtCompact,18 fmtPermille,19 type LineChart,20 type BarChart,21 type BarRow,22 type Series,23 type Spark,24} from '../viz/charts';25import {26 tribeColor,27 BREAKDOWN_KEYS,28 BREAKDOWN_COLORS,29 PROFESSION_SLOT_COLORS,30 OTHER_COLOR,31 IDLE_COLOR,32 TIER_RAMP,33 PROD_COLOR,34 CONS_COLOR,35 CHROME,36} from '../viz/palette';3738const TIER_NAMES = ['camp', 'village', 'town', 'city'] as const;39/** Resources summed into the "food" store line. */40const FOOD_KEYS = ['grain', 'fish', 'meat', 'flour', 'bread'] as const;4142export interface Dashboard {43 el: HTMLElement;44 /** Append a new metrics frame to the history. */45 push(frame: MetricsFrame): void;46 /** Redraw all charts from history (call rAF-throttled while visible). */47 render(): void;48 reset(): void;49 dispose(): void;50}5152function card(title: string, ...body: Array<HTMLElement | null>): HTMLElement {53 return h('div', { class: 'chart-card' }, h('div', { class: 'chart-title' }, title), ...body);54}5556export function createDashboard(names: string[]): Dashboard {57 const history: MetricsFrame[] = [];58 const disposables: Array<{ dispose(): void }> = [];59 const track = <T extends { dispose(): void }>(c: T): T => {60 disposables.push(c);61 return c;62 };63 const name = (id: number) => names[id] ?? `tribe ${id}`;6465 // --- headline stat tiles ---66 const kpi = (label: string) => {67 const value = h('div', { class: 'kpi-value' }, '—');68 const sub = h('div', { class: 'kpi-sub' }, '');69 return { el: h('div', { class: 'kpi-tile' }, h('div', { class: 'kpi-label' }, label), value, sub), value, sub };70 };71 const kLeader = kpi('Leader');72 const kPop = kpi('World population');73 const kBattles = kpi('Battles fought');74 const kTechs = kpi('Techs unlocked');7576 // --- shared tribe legend (secondary encoding for the tribe hues) ---77 const tribeLegend = legendRow(names.map((n, i) => ({ name: n, color: tribeColor(i), line: true })));78 tribeLegend.classList.add('dash-tribe-legend');7980 // --- lens: score ---81 const scoreRace = track(lineChart({ height: 190, labelEndMax: true }));82 const scoreBreakdown = track(stackedBarsH());8384 // --- lens: population ---85 const popChart = track(lineChart({ height: 170 }));86 const profWraps: HTMLElement[] = [];87 const profCharts: LineChart[] = [];88 const profLegendHost = h('div');89 const vitalsHost = h('div', { class: 'sm-grid' });90 interface VitalRow {91 spark: Spark;92 value: HTMLElement;93 }94 const vitals: Array<{ births: VitalRow; deaths: VitalRow; starv: VitalRow }> = [];9596 // --- lens: economy ---97 const econHost = h('div', { class: 'sm-grid econ' });98 interface EconCard {99 rows: Array<{ spark: Spark; value: HTMLElement; series: (m: TribeMetrics) => number[][]; permille?: boolean }>;100 }101 const econCards: EconCard[] = [];102103 // --- lens: military ---104 const powerChart = track(lineChart({ height: 170 }));105 const battlesBars = track(stackedBarsH());106 const casualtiesChart = track(lineChart({ height: 150 }));107108 // --- lens: territory & tech ---109 const territoryChart = track(lineChart({ height: 170 }));110 const techChart = track(lineChart({ height: 170, step: true, yFormat: (v) => String(Math.round(v)) }));111 const tierBars = track(stackedBarsH());112113 // --- table view (the WCAG-clean twin for every chart above) ---114 const tableBody = h('tbody');115116 // spark-row helper: label - sparkline - current value (a stat row, so no117 // hover layer needed — the current value is always visible)118 const sparkRow = (label: string, colors: string[]): { el: HTMLElement; spark: Spark; value: HTMLElement } => {119 const spark = track(sparkline(colors));120 const value = h('span', { class: 'spark-value mono' }, '—');121 const el = h('div', { class: 'spark-row' }, h('span', { class: 'spark-label' }, label), spark.el, value);122 return { el, spark, value };123 };124125 // build per-tribe small multiples once (tribe count is fixed for a sim)126 for (let t = 0; t < names.length; t++) {127 // professions stacked area128 const pc = track(stackedArea({ height: 130 }));129 profCharts.push(pc);130 profWraps.push(131 h('div', { class: 'sm-card', style: `border-left-color:${tribeColor(t)}` },132 h('div', { class: 'sm-head' }, h('span', { class: 'legend-key', style: `background:${tribeColor(t)}` }), name(t)),133 pc.el,134 ),135 );136 // vitals sparks137 const births = sparkRow('births', [CHROME.muted]);138 const deaths = sparkRow('deaths', [CONS_COLOR]);139 const starv = sparkRow('starved', [CONS_COLOR]);140 vitals.push({ births, deaths, starv });141 vitalsHost.append(142 h('div', { class: 'sm-card', style: `border-left-color:${tribeColor(t)}` },143 h('div', { class: 'sm-head' }, h('span', { class: 'legend-key', style: `background:${tribeColor(t)}` }), name(t)),144 births.el, deaths.el, starv.el,145 ),146 );147 // economy card148 const sumFood = (rec: Record<string, number>) => FOOD_KEYS.reduce((a, k) => a + (rec[k] ?? 0), 0);149 const rows: EconCard['rows'] = [];150 const econRow = (label: string, colors: string[], series: (m: TribeMetrics) => number[][], permille = false) => {151 const r = sparkRow(label, colors);152 rows.push({ spark: r.spark, value: r.value, series, permille });153 return r.el;154 };155 econCards.push({ rows });156 econHost.append(157 h('div', { class: 'sm-card', style: `border-left-color:${tribeColor(t)}` },158 h('div', { class: 'sm-head' }, h('span', { class: 'legend-key', style: `background:${tribeColor(t)}` }), name(t)),159 econRow('food', [CHROME.accent], (m) => [[sumFood(m.stores)]]),160 econRow('tools', [CHROME.accent], (m) => [[m.stores.tools]]),161 econRow('weapons', [CHROME.accent], (m) => [[m.stores.weapons]]),162 econRow('coin', [CHROME.accent], (m) => [[m.coin]]),163 econRow('food p/c', [PROD_COLOR, CONS_COLOR], (m) => [[sumFood(m.production)], [sumFood(m.consumption)]]),164 econRow('tools p/c', [PROD_COLOR, CONS_COLOR], (m) => [[m.production.tools], [m.consumption.tools]]),165 econRow('tool cov.', [PROD_COLOR], (m) => [[m.toolCoverage]], true),166 ),167 );168 }169170 const emptyNote = h('p', { class: 'notice dash-empty' }, 'Waiting for the first metrics frame (tick 10)…');171172 const lens = (title: string, ...body: Array<HTMLElement | null>) =>173 h('section', { class: 'lens' }, h('h3', { class: 'lens-title' }, title), ...body);174175 const el = h('div', { class: 'dash' },176 emptyNote,177 h('div', { class: 'kpi-row' }, kLeader.el, kPop.el, kBattles.el, kTechs.el),178 tribeLegend,179 lens('Score',180 h('div', { class: 'chart-grid' },181 card('Score race', scoreRace.el),182 card('Score breakdown — now',183 legendRow(BREAKDOWN_KEYS.map((k) => ({ name: k, color: BREAKDOWN_COLORS[k] }))),184 scoreBreakdown.el),185 ),186 ),187 lens('Population',188 h('div', { class: 'chart-grid' },189 card('Population', popChart.el),190 card('Vitals — per interval', vitalsHost),191 ),192 card('Workforce by profession', profLegendHost, h('div', { class: 'sm-grid' }, ...profWraps)),193 ),194 lens('Economy',195 card('Stores & chain health — per tribe',196 legendRow([197 { name: 'production', color: PROD_COLOR, line: true },198 { name: 'consumption', color: CONS_COLOR, line: true },199 ]),200 econHost),201 ),202 lens('Military',203 h('div', { class: 'chart-grid' },204 card('Warband power', powerChart.el),205 card('Battles won / lost',206 legendRow([207 { name: 'won', color: PROD_COLOR },208 { name: 'lost', color: CONS_COLOR },209 ]),210 battlesBars.el),211 ),212 h('div', { class: 'chart-grid' }, card('Casualties dealt — cumulative', casualtiesChart.el)),213 ),214 lens('Territory & tech',215 h('div', { class: 'chart-grid' },216 card('Territory tiles', territoryChart.el),217 card('Techs unlocked', techChart.el),218 ),219 h('div', { class: 'chart-grid' },220 card('Settlements by tier',221 legendRow(TIER_NAMES.map((n, i) => ({ name: n, color: TIER_RAMP[i]! }))),222 tierBars.el),223 ),224 ),225 lens('Latest numbers',226 h('div', { class: 'chart-card' },227 h('table', { class: 'list metrics-table' },228 h('thead', null, h('tr', null,229 ['Tribe', 'Pop', 'Soldiers', 'Warbands', 'Power', 'Coin', 'Food', 'Tools', 'Territory', 'Techs', 'Score']230 .map((c) => h('th', null, c)))),231 tableBody,232 ),233 ),234 ),235 );236237 /** Latest metrics for tribe id, or null. */238 const latest = (): MetricsFrame | null => history[history.length - 1] ?? null;239240 /** Build a per-tribe Series across history from a metric selector. */241 const tribeSeries = (pick: (m: TribeMetrics) => number): { ticks: number[]; series: Series[] } => {242 const ticks = history.map((f) => f.tick);243 const series: Series[] = names.map((n, id) => ({244 name: n,245 color: tribeColor(id),246 data: history.map((f) => {247 const m = f.tribes.find((tm) => tm.tribeId === id);248 return m ? pick(m) : 0;249 }),250 }));251 return { ticks, series };252 };253254 function render() {255 const last = latest();256 if (!last) return;257 emptyNote.style.display = 'none';258259 // headline260 let leader: TribeMetrics | null = null;261 let popSum = 0;262 let battleSum = 0;263 let techSum = 0;264 for (const m of last.tribes) {265 if (!leader || m.score > leader.score) leader = m;266 popSum += m.pop;267 battleSum += m.battlesWon; // each battle has one winner — no double count268 techSum += m.techsUnlocked;269 }270 if (leader) {271 kLeader.value.textContent = name(leader.tribeId);272 kLeader.value.style.color = tribeColor(leader.tribeId); // name IS the identity273 kLeader.sub.textContent = `score ${fmtCompact(leader.score)}`;274 }275 kPop.value.textContent = fmtCompact(popSum);276 kPop.sub.textContent = `tick ${last.tick}`;277 kBattles.value.textContent = fmtCompact(battleSum);278 kBattles.sub.textContent = `${last.tribes.filter((m) => m.alive).length} tribes alive`;279 kTechs.value.textContent = fmtCompact(techSum);280 kTechs.sub.textContent = 'across all tribes';281282 // score lens283 {284 const { ticks, series } = tribeSeries((m) => m.score);285 scoreRace.update(ticks, series);286 const rows: BarRow[] = last.tribes.map((m) => ({287 label: name(m.tribeId),288 segments: BREAKDOWN_KEYS.map((k) => ({ name: k, color: BREAKDOWN_COLORS[k], value: m.scoreBreakdown[k] })),289 }));290 scoreBreakdown.update(rows);291 }292293 // population lens294 {295 const { ticks, series } = tribeSeries((m) => m.pop);296 popChart.update(ticks, series);297298 // top-8 professions by CURRENT global head count; color slots assigned299 // in PROFESSIONS declaration order so a profession keeps its hue across300 // tribes and (in practice) across the run.301 const totals = new Map<Profession, number>();302 for (const m of last.tribes)303 for (const p of PROFESSIONS) totals.set(p, (totals.get(p) ?? 0) + (m.popByProfession[p] ?? 0));304 const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([p]) => p);305 const ordered = PROFESSIONS.filter((p) => top.includes(p));306 const colorOf = new Map(ordered.map((p, i) => [p, PROFESSION_SLOT_COLORS[i]!]));307 clear(profLegendHost);308 profLegendHost.append(309 legendRow([310 ...ordered.map((p) => ({ name: p, color: colorOf.get(p)! })),311 { name: 'other', color: OTHER_COLOR },312 { name: 'idle', color: IDLE_COLOR },313 ]),314 );315 for (let id = 0; id < names.length; id++) {316 const bands: Series[] = ordered.map((p) => ({317 name: p,318 color: colorOf.get(p)!,319 data: history.map((f) => f.tribes.find((tm) => tm.tribeId === id)?.popByProfession[p] ?? 0),320 }));321 bands.push({322 name: 'other',323 color: OTHER_COLOR,324 data: history.map((f) => {325 const m = f.tribes.find((tm) => tm.tribeId === id);326 if (!m) return 0;327 let rest = 0;328 for (const p of PROFESSIONS) if (!colorOf.has(p)) rest += m.popByProfession[p] ?? 0;329 return rest;330 }),331 });332 bands.push({333 name: 'idle',334 color: IDLE_COLOR,335 data: history.map((f) => f.tribes.find((tm) => tm.tribeId === id)?.idle ?? 0),336 });337 profCharts[id]!.update(ticks, bands);338 }339340 // vitals sparks341 for (let id = 0; id < names.length; id++) {342 const v = vitals[id]!;343 const seq = (pick: (m: TribeMetrics) => number) =>344 history.map((f) => {345 const m = f.tribes.find((tm) => tm.tribeId === id);346 return m ? pick(m) : 0;347 });348 const births = seq((m) => m.births);349 const deaths = seq((m) => m.deaths);350 const starv = seq((m) => m.starvationDeaths);351 v.births.spark.update([births]);352 v.deaths.spark.update([deaths]);353 v.starv.spark.update([starv]);354 v.births.value.textContent = fmtCompact(births[births.length - 1] ?? 0);355 v.deaths.value.textContent = fmtCompact(deaths[deaths.length - 1] ?? 0);356 v.starv.value.textContent = fmtCompact(starv[starv.length - 1] ?? 0);357 }358 }359360 // economy lens361 for (let id = 0; id < names.length; id++) {362 const cardRows = econCards[id]!.rows;363 for (const row of cardRows) {364 // one history pass per row: series(m) returns the per-frame sample(s)365 const seriesCount = row.series(last.tribes.find((tm) => tm.tribeId === id) ?? last.tribes[0]!).length;366 const data: number[][] = Array.from({ length: seriesCount }, () => []);367 for (const f of history) {368 const m = f.tribes.find((tm) => tm.tribeId === id);369 const vals = m ? row.series(m) : null;370 for (let si = 0; si < seriesCount; si++) data[si]!.push(vals ? (vals[si]?.[0] ?? 0) : 0);371 }372 row.spark.update(data);373 const cur = data[0]![data[0]!.length - 1] ?? 0;374 row.value.textContent = row.permille ? fmtPermille(cur) : fmtCompact(cur);375 }376 }377378 // military lens379 {380 const { ticks, series } = tribeSeries((m) => m.warbandPower);381 powerChart.update(ticks, series);382 battlesBars.update(383 last.tribes.map((m) => ({384 label: name(m.tribeId),385 segments: [386 { name: 'won', color: PROD_COLOR, value: m.battlesWon },387 { name: 'lost', color: CONS_COLOR, value: m.battlesLost },388 ],389 })),390 );391 const c = tribeSeries((m) => m.casualtiesDealt);392 casualtiesChart.update(c.ticks, c.series);393 }394395 // territory & tech lens396 {397 const t = tribeSeries((m) => m.territoryTiles);398 territoryChart.update(t.ticks, t.series);399 const k = tribeSeries((m) => m.techsUnlocked);400 techChart.update(k.ticks, k.series);401 tierBars.update(402 last.tribes.map((m) => ({403 label: name(m.tribeId),404 segments: m.settlementsByTier.map((v, i) => ({ name: TIER_NAMES[i]!, color: TIER_RAMP[i]!, value: v })),405 })),406 );407 }408409 // table view410 clear(tableBody as HTMLElement);411 const sorted = [...last.tribes].sort((a, b) => b.score - a.score);412 for (const m of sorted) {413 tableBody.append(414 h('tr', { class: m.alive ? '' : 'dead-row' },415 h('td', null,416 h('span', { class: 'legend-key', style: `background:${tribeColor(m.tribeId)};margin-right:0.4rem` }),417 name(m.tribeId)),418 h('td', { class: 'mono' }, String(m.pop)),419 h('td', { class: 'mono' }, String(m.soldiers)),420 h('td', { class: 'mono' }, String(m.warbands)),421 h('td', { class: 'mono' }, String(m.warbandPower)),422 h('td', { class: 'mono' }, String(m.coin)),423 h('td', { class: 'mono' }, String(FOOD_KEYS.reduce((a, key) => a + (m.stores[key] ?? 0), 0))),424 h('td', { class: 'mono' }, String(m.stores.tools)),425 h('td', { class: 'mono' }, String(m.territoryTiles)),426 h('td', { class: 'mono' }, String(m.techsUnlocked)),427 h('td', { class: 'mono' }, String(m.score)),428 ),429 );430 }431 }432433 return {434 el,435 push(frame) {436 // The worker only posts NEW frames, but restarts can replay ticks —437 // drop anything at-or-before the last stored tick.438 const last = history[history.length - 1];439 if (last && frame.tick <= last.tick) return;440 history.push(frame);441 },442 render,443 reset() {444 history.length = 0;445 emptyNote.style.display = '';446 },447 dispose() {448 for (const d of disposables) d.dispose();449 },450 };451}