Files
Country-Game---The-Desk/web/js/ui/dashboard.js
T

136 lines
6.1 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 = {};
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 };
}