First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user