274 lines
11 KiB
JavaScript
274 lines
11 KiB
JavaScript
// The budget table shared by the nation and city panels. Every row is an item
|
||
// and its hourly amount, income in green and every cost in red, with the
|
||
// projected total on the last row. Building types are rows that expand to their
|
||
// per-city cost. `breakdown` mirrors the shape both the national and the
|
||
// regional economy use, so one renderer serves them both.
|
||
|
||
import { percent, signedMoney, money, SOURCE_LABELS } from "./format.js";
|
||
import { BUDGET_EXPENSE_CATEGORIES, resourceById } from "../../../shared/data.js";
|
||
import { groupDigits } from "../../../shared/text_format.js";
|
||
import { currencySymbol } from "../currency.js";
|
||
|
||
// Draws the table into `$list`. `expanded` is the set of open building types,
|
||
// mutated in place; `onToggle` is called after a caret flips so the caller can
|
||
// redraw. Regions pass `showUnits: false`: unit upkeep is funded nationally.
|
||
export function renderBudget($list, breakdown = {}, { expanded = new Set(), onToggle = null, showUnits = true } = {}) {
|
||
if (!$list || !$list.length) return;
|
||
const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [], regions: [] };
|
||
const incomeSources = income.sources || [];
|
||
const building = breakdown.buildingUpkeep || { total: 0, groups: [], sources: [] };
|
||
const buildingGroups = building.groups || [];
|
||
const units = breakdown.unitUpkeep || { total: 0, groups: [], sources: [] };
|
||
const unitSources = units.sources || [];
|
||
const campaigns = breakdown.campaignUpkeep || { total: 0, sources: [] };
|
||
const campaignSources = campaigns.sources || [];
|
||
const transport = breakdown.transportUpkeep || { total: 0, sources: [] };
|
||
const transportSources = transport.sources || [];
|
||
const resourceMoney = breakdown.resourceMoney || [];
|
||
const net = breakdown.net || 0;
|
||
|
||
$list.empty();
|
||
const $body = $("<tbody></tbody>");
|
||
|
||
$body.append(sectionRow("Income"));
|
||
$body.append(moneyRow("Trade taxes", income.total, "income", null));
|
||
if (incomeSources.length === 0) {
|
||
$body.append(detailRow("No taxes collected", 0, "income-detail"));
|
||
}
|
||
for (const source of incomeSources) {
|
||
const where = source.cityName ? ` in ${source.cityName}` : "";
|
||
// Tax sources carry an amount but no percentage effect; older bonus sources
|
||
// still name their kind and effect.
|
||
const detail = source.effect === undefined
|
||
? `${source.label}${where}`
|
||
: `${source.label}${where} (${SOURCE_LABELS[source.kind] || source.kind}) ${percent(source.effect)}`;
|
||
$body.append(detailRow(detail, source.amount, "income-detail"));
|
||
}
|
||
|
||
$body.append(sectionRow("Expenses"));
|
||
if (buildingGroups.length === 0) {
|
||
$body.append(detailRow("No buildings", 0, "expense-detail"));
|
||
}
|
||
for (const group of buildingGroups) {
|
||
const key = `building:${group.index}`;
|
||
const isExpanded = expanded.has(key);
|
||
$body.append(groupRow(group.label, -group.amount, key, isExpanded, expanded, onToggle));
|
||
if (isExpanded) {
|
||
for (const city of group.cities) {
|
||
$body.append(
|
||
detailRow(
|
||
`${group.label} in ${city.cityName} — level ${city.level}`,
|
||
-city.amount,
|
||
"expense-detail"
|
||
)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (showUnits) {
|
||
if (unitSources.length === 0) {
|
||
$body.append(detailRow("No units", 0, "expense-detail"));
|
||
}
|
||
for (const source of unitSources) {
|
||
$body.append(
|
||
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
|
||
);
|
||
}
|
||
for (const source of campaignSources) {
|
||
$body.append(
|
||
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
|
||
);
|
||
}
|
||
for (const source of transportSources) {
|
||
$body.append(
|
||
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
|
||
);
|
||
}
|
||
}
|
||
|
||
// What the nation spent buying each resource, and earned selling it to other
|
||
// countries, over the last day, shown as an hourly average.
|
||
if (resourceMoney.length > 0) {
|
||
$body.append(sectionRow("Resource trade"));
|
||
for (const entry of resourceMoney) {
|
||
const proto = resourceById(entry.id);
|
||
const name = proto ? proto.name : entry.id;
|
||
if (entry.bought > 0) {
|
||
$body.append(detailRow(`Bought ${name}`, -entry.bought / 24, "expense-detail"));
|
||
}
|
||
if (entry.sold > 0) {
|
||
$body.append(detailRow(`Sold ${name}`, entry.sold / 24, "income-detail"));
|
||
}
|
||
}
|
||
}
|
||
|
||
const $table = $("<table class='budget-table'></table>");
|
||
$table.append($body);
|
||
$table.append($("<tfoot></tfoot>").append(totalRow("TOTAL", net)));
|
||
$list.append($table);
|
||
}
|
||
|
||
// Renders a whole month's actual budget: the tax and resource sales earned, the
|
||
// money each expense category spent, and the resource-trade memo. `report` is
|
||
// the compact shape the snapshot ships (category ids, not labels). The cash
|
||
// spent is the headline; when a category consumed materials worth more than the
|
||
// cash it paid (the stores covered the rest), the row notes the full market
|
||
// value. The treasury itself lives on the panel's subtitle, not in the table.
|
||
export function renderMonthlyBudget($list, report, { expanded = new Set(), onToggle = null } = {}) {
|
||
if (!$list || !$list.length) return;
|
||
$list.empty();
|
||
if (!report) return;
|
||
const $body = $("<tbody></tbody>");
|
||
|
||
$body.append(sectionRow("Income"));
|
||
const taxes = report.taxes || { sales: 0, export: 0, import: 0 };
|
||
$body.append(monthRow("National sales tax", taxes.sales, "income", null));
|
||
$body.append(monthRow("Export tariffs", taxes.export, "income", null));
|
||
$body.append(monthRow("Import tariffs", taxes.import, "income", null));
|
||
if (report.sales > 0) {
|
||
$body.append(monthRow("Resource sales", report.sales, "income-detail", null));
|
||
}
|
||
|
||
$body.append(sectionRow("Expenses"));
|
||
const byId = new Map((report.expenses || []).map((entry) => [entry[0], entry]));
|
||
const sections = [];
|
||
for (const category of BUDGET_EXPENSE_CATEGORIES) {
|
||
const entry = byId.get(category.id);
|
||
if (!entry) continue;
|
||
const last = sections[sections.length - 1];
|
||
if (!last || last.group !== category.group) {
|
||
sections.push({ group: category.group, rows: [] });
|
||
}
|
||
sections[sections.length - 1].rows.push({
|
||
id: category.id,
|
||
label: category.label,
|
||
cash: entry[1],
|
||
economic: entry[2],
|
||
buys: entry[3] || [],
|
||
});
|
||
}
|
||
for (const section of sections) {
|
||
$body.append(subsectionRow(section.group));
|
||
for (const row of section.rows) {
|
||
// The rightmost column is the month's true material cost. When the stores
|
||
// covered part of it only the smaller cash figure left the treasury, so
|
||
// that is noted under the name. Expanding names each resource bought and
|
||
// the buildings, units or works that consumed it.
|
||
const valued = row.economic > row.cash + 1;
|
||
const note = valued ? `cash ${money(-row.cash)}` : null;
|
||
const isOpen = expanded.has(row.id);
|
||
const expandable = row.buys.length > 0;
|
||
const $tr = monthRow(row.label, -row.economic, "expense-detail", note);
|
||
if (expandable) {
|
||
const $name = $tr.find(".budget-name");
|
||
$name.addClass("clickable");
|
||
$name.prepend($("<span class='budget-caret'></span>").text(isOpen ? "▾" : "▸"));
|
||
$name.on("click", () => {
|
||
if (expanded.has(row.id)) expanded.delete(row.id);
|
||
else expanded.add(row.id);
|
||
if (onToggle) onToggle(row.id);
|
||
});
|
||
}
|
||
$body.append($tr);
|
||
if (isOpen) {
|
||
for (const buy of row.buys) {
|
||
const [resource, amount, sources] = buy;
|
||
const proto = resourceById(resource);
|
||
const name = proto ? proto.name : resource;
|
||
$body.append(monthRow(name, -amount, "expense-detail budget-buy", null));
|
||
for (const [source, sourceAmount] of sources || []) {
|
||
$body.append(
|
||
monthRow(source, -sourceAmount, "expense-detail budget-source", null)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const trade = report.resourceTrade || [];
|
||
if (trade.length > 0) {
|
||
$body.append(sectionRow("Resource trade"));
|
||
for (const [id, bought, sold] of trade) {
|
||
const proto = resourceById(id);
|
||
const name = proto ? proto.name : id;
|
||
if (bought > 0) $body.append(monthRow(`Bought ${name}`, -bought, "expense-detail", null));
|
||
if (sold > 0) $body.append(monthRow(`Sold ${name}`, sold, "income-detail", null));
|
||
}
|
||
}
|
||
|
||
const totalNote = report.totalEconomic > report.totalCash + 1
|
||
? `materials valued at ${currencySymbol()}${groupDigits(Math.round(report.totalEconomic))}`
|
||
: null;
|
||
const $table = $("<table class='budget-table'></table>");
|
||
$table.append($body);
|
||
$table.append($("<tfoot></tfoot>").append(monthTotalRow("TOTAL", report.net, totalNote)));
|
||
$list.append($table);
|
||
}
|
||
|
||
function monthRow(label, value, cls, note) {
|
||
const $tr = $("<tr></tr>").addClass(cls);
|
||
const $name = $("<td class='budget-name'></td>").text(label);
|
||
if (note) $name.append($("<div class='budget-note'></div>").text(note));
|
||
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
|
||
return $tr;
|
||
}
|
||
|
||
function monthTotalRow(label, value, note) {
|
||
const $tr = $("<tr class='budget-total'></tr>");
|
||
const $name = $("<td class='budget-name'></td>").text(label);
|
||
if (note) $name.append($("<div class='budget-note'></div>").text(note));
|
||
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
|
||
return $tr;
|
||
}
|
||
|
||
function subsectionRow(label) {
|
||
return $("<tr class='budget-subsection'></tr>").append(
|
||
$("<td colspan='2'></td>").text(label)
|
||
);
|
||
}
|
||
|
||
function sectionRow(label) {
|
||
return $("<tr class='budget-section'></tr>").append(
|
||
$("<td colspan='2'></td>").text(label)
|
||
);
|
||
}
|
||
|
||
function moneyRow(label, value, cls, note) {
|
||
const $tr = $("<tr></tr>").addClass(cls);
|
||
const $name = $("<td class='budget-name'></td>").text(label);
|
||
if (note) $name.append($("<div class='budget-note'></div>").text(note));
|
||
$tr.append($name, $("<td class='budget-value'></td>").text(signedMoney(value)));
|
||
return $tr;
|
||
}
|
||
|
||
function detailRow(label, value, cls) {
|
||
const $tr = $("<tr></tr>").addClass(cls);
|
||
$tr.append($("<td class='budget-name'></td>").text(label));
|
||
$tr.append(
|
||
$("<td class='budget-value'></td>").text(value === 0 ? "—" : signedMoney(value))
|
||
);
|
||
return $tr;
|
||
}
|
||
|
||
function groupRow(label, value, key, isExpanded, expanded, onToggle) {
|
||
const $tr = $("<tr class='budget-group'></tr>");
|
||
const $name = $("<td class='budget-name clickable'></td>");
|
||
$name.append($("<span class='budget-caret'></span>").text(isExpanded ? "▾" : "▸"));
|
||
$name.append(document.createTextNode(` ${label}`));
|
||
$name.on("click", () => {
|
||
if (expanded.has(key)) expanded.delete(key);
|
||
else expanded.add(key);
|
||
if (onToggle) onToggle(key);
|
||
});
|
||
$tr.append($name, $("<td class='budget-value'></td>").text(signedMoney(value)));
|
||
return $tr;
|
||
}
|
||
|
||
function totalRow(label, value) {
|
||
const $tr = $("<tr class='budget-total'></tr>");
|
||
$tr.append($("<td class='budget-name'></td>").text(label));
|
||
$tr.append($("<td class='budget-value'></td>").text(signedMoney(value)));
|
||
return $tr;
|
||
}
|