128 lines
5.8 KiB
JavaScript
128 lines
5.8 KiB
JavaScript
// 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 = `
|
|
<header class="dash__head">
|
|
<h2>${t("dash.title")}</h2>
|
|
<span class="stamp stamp--red">${t("dash.confidential")}</span>
|
|
</header>
|
|
<div class="dash__gauges">
|
|
${MAIN.map((id) => `
|
|
<div class="gauge" data-ind="${id}">
|
|
<span class="gauge__label">${t("ind." + id)}<small class="gauge__band"></small></span>
|
|
<span class="gauge__track"><span class="gauge__fill"></span><i></i><i></i><i></i></span>
|
|
<span class="gauge__value"></span>
|
|
<span class="gauge__spark"></span>
|
|
</div>`).join("")}
|
|
</div>
|
|
<h3 class="dash__sub">${t("dash.econ")}</h3>
|
|
<table class="econ"><tbody>
|
|
${ECON.map(([key, label]) => `
|
|
<tr data-econ="${key}"><th>${t(label)}</th><td class="econ__val"></td><td class="econ__trend"></td></tr>`).join("")}
|
|
</tbody></table>
|
|
<h3 class="dash__sub">${t("dash.intel")} <span class="stamp stamp--small">${t("dash.classified")}</span></h3>
|
|
<ul class="intel">
|
|
<li data-intel="unrest"><span>${t("ind.unrest")}</span><b></b></li>
|
|
<li data-intel="armyLoyalty"><span>${t("ind.armyLoyalty")}</span><b></b></li>
|
|
<li data-intel="regime"><span>${t("ind.regime")}</span><b></b></li>
|
|
</ul>`;
|
|
|
|
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 = {};
|
|
|
|
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);
|
|
g.value.textContent = Math.round(v);
|
|
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 };
|
|
}
|