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

214 lines
8.2 KiB
JavaScript

// Quarterly budget ledger: a two-page accounting book. The left page sets sector
// budgets, the right page sets tax rates and shows the Treasury summary and a
// four-quarter projection. Nothing is applied until the player signs.
import { t, fmt, getLang } from "../i18n/i18n.js";
import {
previewBudget, setBudget, confirmBudget, qualityFromRatio, taxRevenue, TAX_IDS,
} from "../engine/index.js";
import { $ } from "./desk.js";
const ALLOC_STEP = 0.05;
const TAX_STEP = 0.5;
// Projection rows: label key, field on the summary, digits, suffix
const PROJ_ROWS = [
["econ.debt", (p) => p.debtRatio, 1, ""],
["econ.deficit", (p) => p.deficit, 1, ""],
["econ.growth", (p) => p.growth, 1, ""],
["econ.unemployment", (p) => p.unemployment, 1, ""],
["ind.opinion", (p) => p.indicators.opinion, 0, ""],
["ind.stability", (p) => p.indicators.stability, 0, ""],
["ind.economy", (p) => p.indicators.economy, 0, ""],
];
export function createLedger(root, scenario, { onConfirm }) {
let openKey = null;
let ctx = null; // { state, bal }
let draft = null;
let base = null;
let queued = false;
const sectorIds = (state) => Object.keys(state.sectors);
function open(state, bal) {
ctx = { state, bal };
base = {
alloc: Object.fromEntries(Object.entries(state.sectors).map(([id, s]) => [id, s.alloc])),
taxes: { ...state.economy.taxes },
};
draft = structuredClone(base);
const allocMax = Math.min(bal.limits.allocMaxRatio, bal.limits.sliderMaxRatio);
const sectorRows = sectorIds(state).map((id) => {
const s = state.sectors[id];
const max = Math.max(s.need * allocMax, s.alloc);
return `
<div class="lrow" data-sector="${id}">
<label class="lrow__name" for="alloc-${id}" title="${t("sector." + id)}">${t("sector." + id)}</label>
<span class="lrow__ctl">
<input type="range" id="alloc-${id}" min="0" max="${max}" step="${ALLOC_STEP}" value="${s.alloc}">
<i class="lrow__tick" style="left:${(s.need / max) * 100}%"></i>
</span>
<output class="lrow__val"></output>
<span class="lrow__q"></span>
</div>`;
}).join("");
const taxRows = TAX_IDS.map((id) => {
const max = bal.tax[id].peak * bal.limits.taxMaxFactorOfPeak;
return `
<div class="lrow lrow--tax" data-tax="${id}">
<label class="lrow__name" for="tax-${id}">${t("tax." + id)}</label>
<span class="lrow__ctl">
<input type="range" id="tax-${id}" min="0" max="${max}" step="${TAX_STEP}" value="${state.economy.taxes[id]}">
<i class="lrow__tick" style="left:${(bal.tax[id].peak / max) * 100}%"></i>
</span>
<output class="lrow__val"></output>
<span class="lrow__yield"></span>
</div>`;
}).join("");
root.innerHTML = `
<div class="ledger__book">
<article class="ledger__page ledger__page--left">
<h2>${t("ledger.title")}</h2>
<p class="ledger__sub">${t("ledger.spending")}. ${t("ledger.spendingHint")}</p>
<div class="ledger__rows">${sectorRows}</div>
<footer class="ledger__total">${t("ledger.totalAlloc")} <b data-total></b></footer>
</article>
<article class="ledger__page ledger__page--right">
<h2>${t("ledger.revenue")}</h2>
<p class="ledger__sub">${t("ledger.revenueHint")}</p>
<div class="ledger__rows">${taxRows}</div>
<h3>${t("ledger.summary")}</h3>
<dl class="sum" data-summary></dl>
<h3>${t("ledger.projection")}</h3>
<table class="proj"><thead></thead><tbody></tbody></table>
<footer class="ledger__actions">
<button class="brass" data-act="reset">${t("ledger.reset")}</button>
<button class="brass brass--wide" data-act="confirm">${t("ledger.confirm")}</button>
</footer>
<p class="ledger__error" data-error hidden>${t("ledger.error")}</p>
</article>
</div>`;
root.hidden = false;
refresh();
}
function close() {
root.hidden = true;
root.innerHTML = "";
openKey = null;
ctx = null;
}
// Cheap parts (values next to sliders) run immediately; the preview is coalesced per frame.
function refresh() {
const { state, bal } = ctx;
let total = 0;
for (const id of sectorIds(state)) {
const s = state.sectors[id];
const v = draft.alloc[id];
total += v;
const row = $(root, `[data-sector="${id}"]`);
$(row, ".lrow__val").textContent = fmt(v, 2);
const target = qualityFromRatio(v / s.need, bal);
const diff = target - s.quality;
const q = $(row, ".lrow__q");
q.textContent = diff > 1 ? "▲" : diff < -1 ? "▼" : "=";
q.className = `lrow__q ${diff > 1 ? "up" : diff < -1 ? "down" : "flat"}`;
q.title = t("ledger.quality", { now: Math.round(s.quality), target: Math.round(target) });
row.classList.toggle("is-changed", Math.abs(v - base.alloc[id]) > 1e-9);
}
$(root, "[data-total]").textContent = `${fmt(total, 2)} %`;
for (const id of TAX_IDS) {
const row = $(root, `[data-tax="${id}"]`);
$(row, ".lrow__val").textContent = `${fmt(draft.taxes[id], 1)} %`;
$(row, ".lrow__yield").textContent = t("ledger.yield", { n: fmt(taxRevenue(id, draft.taxes[id], bal), 1) });
row.classList.toggle("is-changed", Math.abs(draft.taxes[id] - base.taxes[id]) > 1e-9);
}
if (!queued) {
queued = true;
requestAnimationFrame(() => {
queued = false;
if (ctx) renderPreview();
});
}
}
function renderPreview() {
const { state, bal } = ctx;
const pv = previewBudget(state, bal, draft);
if (!pv.ok) return;
const f = pv.fiscal;
const gdp = state.economy.gdp;
const bn = (pct) => fmt(Math.abs((pct / 100) * gdp), 1);
const balance = -f.deficit; // positive = surplus
$(root, "[data-summary]").innerHTML = `
<dt>${t("ledger.sumRevenue")}</dt><dd>${fmt(f.revenue, 1)} %</dd>
<dt>${t("ledger.sumSectors")}</dt><dd>${fmt(f.allocTotal, 1)} %</dd>
<dt>${t("ledger.sumInterest")}</dt><dd>${fmt(f.interest, 1)} %</dd>
<dt>${t("ledger.sumSpending")}</dt><dd>${fmt(f.spending, 1)} %</dd>
<dt class="sum__balance ${balance >= 0 ? "good" : "bad"}">${t(balance >= 0 ? "ledger.sumSurplus" : "ledger.sumDeficit")}</dt>
<dd class="sum__balance ${balance >= 0 ? "good" : "bad"}">${fmt(Math.abs(balance), 1)} %
<small>${t("ledger.perYear", { n: bn(balance), currency: scenario.currency })}</small></dd>`;
const months = (i) => (i === 0 ? t("ledger.now") : t("ledger.months", { n: i * 3 }));
$(root, ".proj thead").innerHTML = `<tr><th></th>${pv.points.map((_, i) => `<th>${months(i)}</th>`).join("")}</tr>`;
$(root, ".proj tbody").innerHTML = PROJ_ROWS.map(([label, get, digits, suffix]) =>
`<tr><th>${t(label)}</th>${pv.points.map((p) => `<td>${fmt(get(p), digits)}${suffix}</td>`).join("")}</tr>`).join("");
}
function syncSliders() {
for (const id of sectorIds(ctx.state)) $(root, `#alloc-${id}`).value = draft.alloc[id];
for (const id of TAX_IDS) $(root, `#tax-${id}`).value = draft.taxes[id];
}
root.addEventListener("input", (e) => {
const input = e.target;
if (!ctx || input.type !== "range") return;
const v = Number(input.value);
if (input.id.startsWith("alloc-")) draft.alloc[input.id.slice(6)] = v;
else if (input.id.startsWith("tax-")) draft.taxes[input.id.slice(4)] = v;
refresh();
});
root.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b || !ctx) return;
if (b.dataset.act === "reset") {
draft = structuredClone(base);
syncSliders();
refresh();
} else {
const { state, bal } = ctx;
const res = setBudget(state, bal, draft);
if (!res.ok) {
$(root, "[data-error]").hidden = false;
return;
}
confirmBudget(state);
close();
onConfirm();
}
});
function update(state, bal) {
if (!(state.pending.review && !state.pending.profile && !state.over)) {
if (openKey !== null) close();
return;
}
const key = `${state.day}:${getLang()}`;
if (key !== openKey) {
openKey = key;
open(state, bal);
}
}
return { update };
}