// Ministerial briefing: seven indicator gauges with sparklines, economic figures // with trend arrows, and a classified intelligence summary for the hidden gauges. import { t, fmt } from "../i18n/i18n.js"; import { sparkline } from "./charts.js"; import { $ } from "./desk.js"; import { summarize } from "../engine/index.js"; const MAIN = ["order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower"]; // key, label, digits, suffix, summary field, history series, noise floor for the arrow, good when rising const ECON = [ ["gdp", "econ.gdp", 0, "", "gdp", null, 0, true], ["growth", "econ.growth", 1, " %", "growth", "growth", 0.15, true], ["unemployment", "econ.unemployment", 1, " %", "unemployment", "unemployment", 0.15, false], ["inflation", "econ.inflation", 1, " %", "inflation", "inflation", 0.2, false], ["debt", "econ.debt", 0, " %", "debtRatio", "debtRatio", 1, false], ["deficit", "econ.deficit", 1, " %", "deficit", "deficit", 0.3, false], ["rate", "econ.rate", 2, " %", "interestRate", null, 0, false], ]; const bandIndex = (v, cuts) => cuts.filter((c) => v >= c).length; const OPINION_CUTS = [15, 30, 45, 60, 75, 90]; const UNREST_CUTS = [20, 35, 50, 65]; const ARMY_CUTS = [25, 45, 65]; const SPARK_WEEKS = 26; const SPARK_W = 76; const MIN_SPAN = 16; export function createDashboard(root, scenario) { root.innerHTML = `

${t("dash.title")}

${t("dash.confidential")}
${MAIN.map((id) => `
${t("ind." + id)}
`).join("")}

${t("dash.econ")}

${ECON.map(([key, label]) => ` `).join("")}
${t(label)}

${t("dash.intel")} ${t("dash.classified")}

`; const gauges = Object.fromEntries(MAIN.map((id) => [id, { el: $(root, `.gauge[data-ind="${id}"]`), fill: $(root, `.gauge[data-ind="${id}"] .gauge__fill`), value: $(root, `.gauge[data-ind="${id}"] .gauge__value`), band: $(root, `.gauge[data-ind="${id}"] .gauge__band`), spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`), }])); const sparkLen = {}; const shownValue = {}; function trend(series, eps, goodUp) { if (!series || series.length < 2) return ["", ""]; const cur = series[series.length - 1]; const prev = series[Math.max(0, series.length - 5)]; const d = cur - prev; if (Math.abs(d) < eps) return ["▬", "flat"]; return [d > 0 ? "▲" : "▼", d > 0 === goodUp ? "good" : "bad"]; } function update(state, bal) { const sum = summarize(state, bal); for (const id of MAIN) { const g = gauges[id]; const v = state.indicators[id]; g.fill.style.width = `${v}%`; g.el.classList.toggle("is-low", v < 30); g.el.classList.toggle("is-high", v >= 70); const r = Math.round(v); g.value.textContent = r; if (shownValue[id] !== undefined && shownValue[id] !== r) { g.value.classList.remove("is-up", "is-down"); void g.value.offsetWidth; // restart the animation g.value.classList.add(r > shownValue[id] ? "is-up" : "is-down"); } shownValue[id] = r; if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS)); const series = state.history.series[id] ?? []; if (sparkLen[id] !== series.length) { sparkLen[id] = series.length; const win = series.slice(-SPARK_WEEKS); // Zoom on the data (at least MIN_SPAN points tall) so small trends stay visible. const mid = (Math.min(...win) + Math.max(...win)) / 2; const span = Math.max(Math.max(...win) - Math.min(...win), MIN_SPAN); g.spark.innerHTML = sparkline(win, { w: SPARK_W, min: mid - span / 2, max: mid + span / 2 }); } } for (const [key, , digits, suffix, field, seriesId, eps, goodUp] of ECON) { const row = $(root, `[data-econ="${key}"]`); const val = sum[field]; $(row, ".econ__val").textContent = key === "gdp" ? `${fmt(val, 0)} ${t("econ.gdpUnit", { currency: scenario.currency })}` : `${fmt(val, digits)}${suffix}`; const [arrow, cls] = trend(seriesId && state.history.series[seriesId], eps, goodUp); const cell = $(row, ".econ__trend"); cell.textContent = arrow; cell.className = `econ__trend ${cls}`; } $(root, '[data-intel="unrest"] b').textContent = t("unrest." + bandIndex(state.indicators.unrest, UNREST_CUTS)); $(root, '[data-intel="armyLoyalty"] b').textContent = t("army." + bandIndex(state.indicators.armyLoyalty, ARMY_CUTS)); // Hold on power: Critical once any danger zone has been entered, Shaky when close to one. const i = state.indicators; const inZone = state.risk.revolution > 0 || state.risk.coup > 0 || state.risk.impeachment > 0 || state.risk.defaultZone; const close = i.unrest >= 55 || i.armyLoyalty <= 45 || i.opinion <= 30 || i.stability <= 40 || i.civilRights <= 20 || sum.debtRatio >= 130; const regime = inZone ? 2 : close ? 1 : 0; $(root, '[data-intel="regime"] b').textContent = t("regime." + regime); $(root, '[data-intel="regime"]').dataset.level = [0, 2, 3][regime]; $(root, '[data-intel="unrest"]').dataset.level = bandIndex(state.indicators.unrest, UNREST_CUTS); $(root, '[data-intel="armyLoyalty"]').dataset.level = 3 - bandIndex(state.indicators.armyLoyalty, ARMY_CUTS); } return { update }; }