// ---- Canonical published data ----------------------------------------
// Values come from the six dataset files; labels, row order, signs and entity
// names come from the published metadata. Nothing here recalculates an
// economic series: every figure is read from the canonical data as published.
layout = d3.csv("assets/data/current/statement-layout.csv")// One file per statement family, fetched only when that family is selected
valueFiles = ({
"NIS-A": "assets/data/current/nis-annual.csv",
"NIS-Q": "assets/data/current/nis-quarterly.csv",
"CIS-A": "assets/data/current/cis-annual.csv",
"CIS-Q": "assets/data/current/cis-quarterly.csv",
"IIS-A": "assets/data/current/iis-annual.csv",
"IIS-Q": "assets/data/current/iis-quarterly.csv"
})// The industry picker: All Industries, Government, and the major private
// sectors (the children of Private Goods and Private Services)
majorSectors = {
const label = id => entityMeta.find(e => e.entity_id === id)?.entity_label ?? id;
const kids = parent => entityMeta
.filter(e => e.parent_entity_id === parent)
.map(e => ({id: e.entity_id, label: e.entity_label}))
.sort((a, b) => a.label.localeCompare(b.label));
return [
{id: "Gov", label: label("Gov")},
...kids("PvtGds"),
...kids("PvtSrvs")
];
}// Rows for this family, in published order, grouped into their sections
sections = {
const rows = layout
.filter(l => l.statement_family === family)
.sort((a, b) => +a.section_order - +b.section_order || +a.row_order - +b.row_order);
const out = [];
for (const r of rows) {
const meta = seriesIndex.get(`${family}|${r.series_id}`);
const last = out[out.length - 1];
const row = {
seriesId: r.series_id,
label: meta?.nisp_label ?? r.series_id,
definition: meta?.definition ?? "",
unit: meta?.unit_id ?? "usd_millions",
indent: +r.indent_level,
rowType: r.row_type,
invert: r.presentation_sign === "invert",
policy: r.display_policy
};
if (last && last.id === r.section_id) last.rows.push(row);
else out.push({id: r.section_id, label: r.section_label, rows: [row]});
}
return out;
}// The BEA tables behind the rows on screen, taken from series-metadata
sourceTables = {
const shown = new Set(sections.flatMap(sec => sec.rows.map(r => r.seriesId)));
const groups = new Map();
for (const sid of shown) {
const meta = seriesIndex.get(`${family}|${sid}`);
if (!meta || !meta.source_dataset) continue; // derived series cite no table
const key = meta.source_dataset;
if (!groups.has(key)) groups.set(key, new Map());
groups.get(key).set(meta.source_table_id, meta.source_table_label || meta.source_table_id);
}
return [...groups].map(([dataset, tables]) => ({
dataset,
tables: [...tables.values()].sort()
}));
}// Presentation only: thousands separators, percentages for ratio series, and
// parentheses where the layout marks a row as subtracted.
// Canonical values are $ millions; the statement displays $ billions, which
// is scaling for presentation only — the published figures are unchanged.
toBillions = v => v / 1000fmtValue = (raw, unit, invert) => {
if (raw === "" || raw == null) return null;
const v = +raw;
if (!Number.isFinite(v)) return null;
if (unit === "ratio") return d3.format(".1%")(v);
if (unit === "thousand_fte" || unit === "usd_per_fte") return d3.format(",.0f")(v);
const shown = isMoney(unit) ? toBillions(v) : v;
const s = d3.format(",.1f")(Math.abs(shown));
return invert || v < 0 ? `(${s})` : s;
}// A row's published value, negated where the layout marks it as subtracted
signedValue = (row, period, scale = false) => {
const raw = valueIndex.get(`${row.seriesId}|${period}`);
if (raw === "" || raw == null) return null;
const v = +raw;
if (!Number.isFinite(v)) return null;
const shown = scale && isMoney(row.unit) ? toBillions(v) : v;
return row.invert ? -shown : shown;
}// The workbook mirrors what is on screen: the same rows, in the same published
// order, with the same signs. Values are written as numbers so the sheet can be
// used directly; nothing is recalculated.
function statementWorkbook() {
const aoa = [];
aoa.push([`${scope === "Industry" ? entityLabel : scope} Income Statement`]);
aoa.push([`${entityLabel} · ${frequency} · ${unitsLabel}`]);
aoa.push([`Dataset ${family} · build ${build?.build_id ?? "n/a"}`]);
aoa.push([`Source: ${vintage?.source_agency ?? "BEA"} — ${vintage?.source_release_name ?? "n/a"}` +
` (released ${vintage?.source_release_date ?? "n/a"})`]);
aoa.push([]);
aoa.push(["Line", ...periods.map(fmtPeriod)]);
for (const section of sections) {
if (section.label) aoa.push([section.label]);
for (const row of section.rows) {
const indent = " ".repeat(row.indent);
const hint = unitHint(row.unit) ? ` ${unitHint(row.unit)}` : "";
aoa.push([indent + row.label + hint,
...periods.map(p => {
const v = signedValue(row, p, true); // workbook shows billions too
return v == null ? "n/a" : v;
})]);
}
}
const sheet = XLSX.utils.aoa_to_sheet(aoa);
sheet["!cols"] = [{wch: 46}, ...periods.map(() => ({wch: 16}))];
// Traditional statement presentation: negatives in parentheses rather than
// with a minus sign, percentages for the ratio rows. The underlying cells
// stay numeric, so the workbook is still usable for arithmetic.
const moneyFormat = "#,##0.0_);(#,##0.0)";
const ratioFormat = "0.0%_);(0.0%)";
const unitFormat = unit => unit === "ratio" ? ratioFormat
: (unit === "thousand_fte" || unit === "usd_per_fte") ? "#,##0"
: moneyFormat;
let r = 6; // first data row, zero-based
for (const section of sections) {
if (section.label) r += 1;
for (const row of section.rows) {
for (let c = 1; c <= periods.length; c++) {
const cell = sheet[XLSX.utils.encode_cell({r, c})];
if (!cell) continue;
if (typeof cell.v === "number") {
cell.z = unitFormat(row.unit);
} else {
cell.z = '@'; // "n/a" stays text
}
}
r += 1;
}
}
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, "Income Statement");
// A second sheet carrying the provenance, so the file can stand alone
const notes = [
["EARL Income Statement"],
["Entity", entityLabel],
["Scope", scope],
["Frequency", frequency],
["Dataset", family],
["Periods", periods.length ? `${fmtPeriod(periods[periods.length - 1])} to ${fmtPeriod(periods[0])}` : "n/a"],
["Units", `${unitsLabel} (canonical values are $ millions, scaled for display)`],
["Build", build?.build_id ?? "n/a"],
["Source release", vintage?.source_release_name ?? "n/a"],
["Release date", vintage?.source_release_date ?? "n/a"],
["Release notice", vintage?.source_release_url ?? ""],
[],
["Underlying BEA tables"],
...sourceTables.map(g => [g.dataset, g.tables.join(", ")]),
[],
["Note", "Values are the canonical published figures. Rows marked as " +
"subtractions in the EARL statement layout are stored as negative numbers " +
"and displayed in parentheses, following statement convention."]
];
const notesSheet = XLSX.utils.aoa_to_sheet(notes);
notesSheet["!cols"] = [{wch: 20}, {wch: 80}];
XLSX.utils.book_append_sheet(book, notesSheet, "Source");
return book;
}// The same statement as CSV: one row per line, raw published values, so the
// file is easy to read in other tools. No parentheses, no thousands separators.
function statementCsv() {
const esc = v => {
const s = String(v ?? "");
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const lines = [];
lines.push(["section", "line", "series_id", "unit", "row_type",
...periods.map(fmtPeriod)].map(esc).join(","));
for (const section of sections) {
for (const row of section.rows) {
lines.push([
section.label || section.id,
row.label + (unitHint(row.unit) ? ` ${unitHint(row.unit)}` : ""),
row.seriesId,
row.unit,
row.rowType,
...periods.map(p => {
const v = signedValue(row, p);
return v == null ? "" : v;
})
].map(esc).join(","));
}
}
return lines.join("\n") + "\n";
}downloadName = {
const who = (scope === "Industry" ? entityLabel : scope)
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const span = periods.length
? `${periods[periods.length - 1]}-${periods[0]}`.replace(/Q/g, "q") : "";
return `earl-income-statement-${who}-${frequency.toLowerCase()}-${span}`;
}// Units as published: the money series carry usd_millions, and some
// quarterly datasets are seasonally adjusted annual rates
unitsLabel = {
const units = new Set(sections.flatMap(sec => sec.rows.map(r => r.unit)));
units.delete("ratio"); // margins are always percentages
if (!units.size) return "ratios";
const money = [...units].filter(u => u.startsWith("usd_millions"));
const other = [...units].filter(u => !u.startsWith("usd_millions"));
const base = money.some(u => u.endsWith("_saar")) ? "$ Billions, SAAR" : "$ Billions";
// IIS-A carries headcount and per-FTE rows, so the header cannot claim
// one unit for the whole statement
return other.length ? `${base}, except where noted` : base;
}function metaRow() {
const ready = periods.length > 0;
const xlsxBtn = html`<button type="button" class="nisp-download" ${ready ? "" : "disabled"}
title="Excel workbook: monetary figures in displayed $ billions">Excel (.xlsx)</button>`;
xlsxBtn.onclick = () => XLSX.writeFile(statementWorkbook(), `${downloadName}.xlsx`);
const csvBtn = html`<button type="button" class="nisp-download" ${ready ? "" : "disabled"}
title="CSV: monetary figures in canonical $ millions">CSV (.csv)</button>`;
csvBtn.onclick = () => {
const blob = new Blob([statementCsv()], {type: "text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = html`<a href="${url}" download="${downloadName}.csv"></a>`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
return html`<div class="nisp-meta-row">
<div class="nisp-meta-facts">
<span><strong>Source release:</strong> ${vintage?.source_agency ?? "BEA"},
${vintage?.source_release_date ?? build?.source_vintage_ids ?? "n/a"}</span>
<span><strong>Dataset:</strong> ${family}</span>
<span><strong>Units:</strong> ${unitsLabel}</span>
</div>
<div class="nisp-meta-download-row">
<div class="nisp-download-group">${xlsxBtn}${csvBtn}</div>
<small class="nisp-export-unit-note">Excel: displayed $ billions · CSV: canonical $ millions.
Ratios and nonmonetary measures retain their own units.</small>
</div>
</div>`;
}// Two-position slider: the knob moves between the labels
function slider(options, value) {
const el = html`<div class="nisp-slider">
<span class="nisp-slider-label">${options[0]}</span>
<button type="button" class="nisp-slider-track" role="switch"
aria-label="${options[0]} or ${options[1]}">
<span class="nisp-slider-knob"></span>
</button>
<span class="nisp-slider-label">${options[1]}</span>
</div>`;
const labels = [...el.querySelectorAll(".nisp-slider-label")];
const track = el.querySelector(".nisp-slider-track");
const paint = () => {
const second = el.value === options[1];
track.classList.toggle("on", second);
track.setAttribute("aria-checked", second);
labels.forEach((l, i) => l.classList.toggle("is-on", i === (second ? 1 : 0)));
};
const set = v => { el.value = v; paint(); el.dispatchEvent(new Event("input", {bubbles: true})); };
track.onclick = () => set(el.value === options[0] ? options[1] : options[0]);
labels.forEach((l, i) => l.onclick = () => set(options[i]));
el.value = value;
paint();
return el;
}// Slider with one stop per timeline option
function rangeSlider(options, value) {
const el = html`<div class="nisp-range">
<input type="range" class="nisp-range-input" min="0" max="${options.length - 1}" step="1"
aria-label="Timeline">
<div class="nisp-range-ticks">
${options.map(o => html`<button type="button" class="nisp-range-tick" data-v="${o}">${o}</button>`)}
</div>
</div>`;
const input = el.querySelector("input");
const ticks = [...el.querySelectorAll(".nisp-range-tick")];
const paint = () => {
const i = options.indexOf(el.value);
input.value = i;
// "is-on", not "active": Bootstrap styles .active buttons with a grey fill
ticks.forEach((t, n) => t.classList.toggle("is-on", n === i));
};
const set = v => { el.value = v; paint(); el.dispatchEvent(new Event("input", {bubbles: true})); };
input.oninput = () => set(options[+input.value]);
ticks.forEach((t, i) => t.onclick = () => set(options[i]));
el.value = value;
paint();
return el;
}// National / Corporate / Industry as siblings, industries in two columns
function filterTree() {
const industryOn = scope === "Industry";
const level = (label, name) => {
const isSel = scope === name;
const el = html`<button type="button" class="nisp-level-btn${isSel ? " selected" : ""}"
aria-pressed="${isSel}">${label}</button>`;
el.onclick = () => { mutable scope = name; };
return el;
};
const leaf = sector => {
const isSel = industryOn && entityId === sector.id;
const el = html`<button type="button" class="nisp-leaf${isSel ? " selected" : ""}"
aria-pressed="${isSel}" ${industryOn ? "" : "disabled"}
title="${sector.label}">${sector.label}</button>`;
el.onclick = () => { mutable industryId = sector.id; };
return el;
};
return html`<div class="nisp-tree">
<div class="nisp-level-row">
${level("National", "National")}
${level("Corporate", "Corporate")}
${level("Industry", "Industry")}
</div>
<div class="nisp-industry-grid${industryOn ? "" : " is-disabled"}">
${majorSectors.map(leaf)}
</div>
</div>`;
}function statement() {
if (!periods.length) return html`<div class="nisp-statement">
<div class="nisp-statement-title">${scope === "Industry" ? entityLabel : scope} Income Statement</div>
<p class="nisp-cat-empty">No published data for ${entityLabel} at this frequency.</p>
</div>`;
const cell = (row, period) => {
const shown = fmtValue(valueIndex.get(`${row.seriesId}|${period}`), row.unit, row.invert);
return shown == null
? html`<td class="num na">n/a</td>`
: html`<td class="num">${shown}</td>`;
};
// Prefixed: a bare "ratio" class would hit Bootstrap's aspect-ratio utility
const rowEl = row => html`<tr class="row-${row.rowType}">
<td class="indent-${row.indent}" title="${row.definition}">${row.label}${
unitHint(row.unit)
? html`<span class="nisp-unit-hint"> ${unitHint(row.unit)}</span>` : ""}</td>
${periods.map(p => cell(row, p))}
</tr>`;
const sectionEls = list => list.flatMap(s => [
s.label ? html`<tr class="row-section"><td colspan="${periods.length + 1}">${s.label}</td></tr>` : "",
...s.rows.map(rowEl)
]);
const head = () => html`<thead><tr><th></th>
${periods.map(p => html`<th class="num">${fmtPeriod(p)}</th>`)}
</tr></thead>`;
// Every published section, supplemental ones included, in one statement
// National / Corporate / the industry's own name
const title = scope === "Industry" ? `${entityLabel} Income Statement`
: `${scope} Income Statement`;
return html`<div class="nisp-statement">
<div class="nisp-statement-title">${title}</div>
<div class="nisp-table-wrap">
<table class="nisp-is">${head()}<tbody>${sectionEls(sections)}</tbody></table>
</div>
</div>`;
}function sourceNote() {
const who = scope === "Industry" ? entityLabel : `${scope} statement`;
const tableList = sourceTables.map(g =>
html`<li><strong>${g.dataset}</strong>: ${g.tables.join(", ")}</li>`);
return html`<section class="nisp-provenance" aria-label="Data source and vintage">
<div class="nisp-provenance-head">
<strong>Data source & vintage</strong>
<span class="nisp-vintage-date">${vintage?.source_agency ?? "BEA"} · ${vintage?.source_release_date ?? "n/a"}</span>
</div>
<p>EARL preserves the documented source vintage for this ${who}; newer BEA estimates
may supersede some observations. Income-statement labels are economic analogues,
not corporate accounting identities.</p>
${scope === "Corporate" ? html`<p>The Corporate statement starts at Gross Profit;
comparable Gross Revenue and Cost of Goods Sold rows are not included in its
published layout.</p>` : ""}
<details class="nisp-source-details">
<summary>BEA source tables, methodology & build details</summary>
<div class="nisp-source-details-body">
<p><strong>Source release:</strong> ${vintage?.source_release_name ?? "Not specified"}
· ${vintage?.source_release_date ?? "n/a"}
${vintage?.source_release_url ? html` · <a class="nisp-source-link"
href="${vintage.source_release_url}" target="_blank" rel="noopener noreferrer">Archived BEA release notice</a>` : ""}</p>
<p><strong>Dataset:</strong> ${family} · <strong>Build:</strong> <code>${build?.build_id ?? "n/a"}</code>
· <strong>Coverage:</strong> ${build?.first_period ?? "?"}–${build?.last_period ?? "?"}</p>
${tableList.length ? html`<p><strong>Underlying BEA tables</strong></p>
<ul class="nisp-source-tables">${tableList}</ul>` : ""}
<p>Figures use canonical published values; only display units and prescribed
subtraction signs change. No source series are recomputed in the browser.
<a class="nisp-source-link" href="method.html">Read EARL's methodology</a>.</p>
</div>
</details>
</section>`;
}