// ---- The shared chart contract ---------------------------------------
// chart-registry.csv is Jacky's artifact (supporting/chart_contract/ on
// revamp-data-architecture), mirrored here so the page can read it. The
// Website exposes only the chart states this registry declares; it does not
// invent chart products and does not implement analytical transformations.
registry = d3.csv("assets/data/contract/chart-registry.csv")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 active registry row: one renderable chart-product configuration. Distribution has two products per
// frequency (growth distribution and margin-change distribution), so the row is the one whose series contain the
// selected measure; otherwise the first, as for every other family.
row = rowsHere.find(r => seriesId && r.series_ids.split(";").map(s => s.trim()).includes(seriesId)) ??
rowsHere[0] ?? null// Trend and Income Structure are separate registry products, but the reader
// picks between them in one dropdown. Selecting the structure entry routes to
// the income_structure row rather than inventing a measure the registry
// does not declare.
rowFor = fam => registry.find(r => r.chart_family === fam && r.scope === scope &&
r.frequency === frequency) ?? null// Industry entities, used where the row asks for a single entity
industrySectors = {
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 [...kids("PvtGds"), ...kids("PvtSrvs")];
}activeEntity = {
if (!row) return null;
if (row.entity_mode === "fixed_aggregate") return row.scope; // national / corporate
if (row.entity_mode === "cross_section") return null; // many entities
const ids = industrySectors.map(s => s.id);
// The registry's default_entity_rule is all_industries, but the All
// Industries aggregate is not offered on this page, so the first sector
// stands in as the default
return ids.includes(entityId) ? entityId : ids[0];
}// Rows whose renderer applies an analytical transformation (Growth, Distribution) plot values the chart workstream
// supplies in assets/data/charts/<chart_id>.json (scripts/charts/chart_pagedata.R). The Website selects, slices and
// formats them; it does not compute them.
chartDataRenderers = ["timeseries_growth_line", "historical_distribution_boxplot"]// Canonical values are $ millions; charts display $ billions. Scaling for
// presentation only, and never applied to ratio series.
valueAt = (series, period) => {
const raw = valueIndex.get(`${series}|${period}`);
if (raw === "" || raw == null) return null;
const v = +raw;
if (!Number.isFinite(v)) return null;
return isRatio(series) ? v : v / 1000;
}// One common valid reference period for cross-sectional charts: the latest
// period every peer publishes, never each peer's own latest observation
commonPeriod = {
if (!row || row.entity_mode !== "cross_section" || !activeSeries) return null;
const byPeriod = new Map();
const wanted = new Set(peerSectors.map(s => s.id));
for (const d of values) {
if (d.series_id !== activeSeries || !wanted.has(d.entity_id) || d.value === "") continue;
if (!byPeriod.has(d.period)) byPeriod.set(d.period, new Set());
byPeriod.get(d.period).add(d.entity_id);
}
const complete = [...byPeriod.entries()]
.filter(([, ents]) => ents.size === wanted.size)
.map(([p]) => p)
.sort();
return complete.length ? complete[complete.length - 1] : null;
}crossSectionRows = {
if (!commonPeriod) return [];
const byEntity = new Map();
const scale = isRatio(activeSeries) ? 1 : 1 / 1000;
for (const d of values) {
if (d.period !== commonPeriod || d.series_id !== activeSeries) continue;
byEntity.set(d.entity_id, d.value === "" ? null : +d.value * scale);
}
return peerSectors
.map(s => ({label: s.label, v: byEntity.get(s.id) ?? null}))
.filter(d => d.v != null)
.sort((a, b) => b.v - a.v);
}// Discrete period positions preserve every plotted observation; only axis labels
// are thinned. Plot's `ticks` array works with point scales, and keeps labels
// horizontal across annual/quarterly frequencies and long historical windows.
timeAxis = (availablePeriods, selectedFrequency, selectedHistory, plotWidth) => {
const all = [...new Set(availablePeriods)].filter(Boolean).sort();
if (!all.length) return {type: "point", label: null, labelArrow: "none"};
const startYear = +all[0].slice(0, 4);
const endYear = +all[all.length - 1].slice(0, 4);
const maxLabels = Math.max(3, Math.min(9, Math.floor(plotWidth / 95)));
const span = endYear - startYear + 1;
const steps = [1, 2, 5, 10, 20, 25, 50, 100];
const preferred = selectedHistory === "5Y" ? 1 : selectedHistory === "10Y" ? 2 :
(steps.find(step => Math.ceil(span / step) <= maxLabels) ?? 100);
const tickPeriods = all.filter(p => {
const year = +p.slice(0, 4);
return year % preferred === 0 &&
(selectedFrequency === "annual" || p.endsWith("Q1"));
});
// Very short or incomplete histories can have no aligned reference year.
// In that case still show a useful endpoint, never every quarter by default.
if (!tickPeriods.length) tickPeriods.push(all[0]);
return {type: "point", label: null, labelArrow: "none", ticks: tickPeriods,
tickRotate: 0, tickFormat: p => p.slice(0, 4)};
}// ---- Controls --------------------------------------------------------
// Interaction model from section 9: family -> scope -> measure -> frequency
function familyPicker() {
const scopesFor = f => ["national", "corporate", "industry"]
.filter(sc => registry.some(r => r.chart_family === f && r.scope === sc));
const btn = f => {
// the structure product sits under Trend in the selector, so Trend stays lit
const on = family === f || (f === "trend" && family === "income_structure");
const el = html`<button type="button" class="nisp-family-btn${on ? " selected" : ""}"
aria-pressed="${on}">${familyLabels[f]}</button>`;
el.onclick = () => {
mutable family = f;
// Some families are industry-only, so move the scope with the family
// rather than landing the reader on a state the registry does not declare
const allowed = scopesFor(f);
if (!allowed.includes(scope)) mutable scope = allowed[0];
};
return el;
};
return html`<div class="nisp-family-list">
<span class="nisp-control-label">Chart family</span>
${families.map(btn)}
</div>`;
}function scopePicker() {
const level = s => {
const on = scope === s;
const off = !scopesForFamily.includes(s);
const el = html`<button type="button" class="nisp-level-btn${on ? " selected" : ""}"
aria-pressed="${on}" ${off ? "disabled" : ""}>${scopeLabels[s]}</button>`;
el.onclick = () => { mutable scope = s; };
return el;
};
const singleEntity = row?.entity_mode === "single_entity";
const leaf = sector => {
const on = singleEntity && activeEntity === sector.id;
const el = html`<button type="button" class="nisp-leaf${on ? " selected" : ""}"
aria-pressed="${on}" ${singleEntity ? "" : "disabled"}
title="${sector.label}">${sector.label}</button>`;
el.onclick = () => { mutable entityId = sector.id; };
return el;
};
return html`<div class="nisp-tree">
<span class="nisp-control-label">Scope</span>
<div class="nisp-level-row">
${["national", "corporate", "industry"].map(level)}
</div>
<div class="nisp-industry-grid${singleEntity ? "" : " is-disabled"}">
${industrySectors.map(leaf)}
</div>
</div>`;
}function frequencyPicker() {
const freqBtn = f => {
const on = frequency === f;
const off = !frequenciesForState.includes(f);
const el = html`<button type="button" class="nisp-seg${on ? " active" : ""}"
aria-pressed="${on}" ${off ? "disabled" : ""}>${f === "annual" ? "Annual" : "Quarterly"}</button>`;
el.onclick = () => { mutable frequency = f; };
return el;
};
return html`<div class="nisp-freq-block top">
<span class="nisp-control-label">Frequency</span>
<div class="nisp-segs">${["annual", "quarterly"].map(freqBtn)}</div>
</div>`;
}function historyPicker() {
const histBtn = h => {
const on = activeHistory === h;
const el = html`<button type="button" class="nisp-seg${on ? " active" : ""}"
aria-pressed="${on}">${h}</button>`;
el.onclick = () => { mutable history = h; };
return el;
};
return html`<div class="nisp-freq-block">
${showHistory
? html`<span class="nisp-control-label">History</span>
<div class="nisp-segs">${historyOptions.map(histBtn)}</div>`
: html`<span class="nisp-control-note">
${row?.chart_family === "by_industry"
? `Common reference period: ${commonPeriod ? fmtPeriod(commonPeriod) : "none available"}`
: "History not applicable to this chart."}</span>`}
</div>`;
}function controlRow() {
if (!row) return html`<div class="nisp-ch-controlrow">
<span class="nisp-control-note">No registry row for
${familyLabels[family]} · ${scopeLabels[scope]} · ${frequency}.
The Website exposes only states declared in the chart registry.</span>
</div>`;
// Trend and Income Structure share one selector
const unified = (family === "trend" || family === "income_structure") &&
trendRow && structureRow;
const measureControl = unified
? (() => {
const trendSeries = trendRow.series_ids.split(";").map(x => x.trim()).filter(Boolean);
const labelFor = id => seriesMeta.find(m =>
m.dataset_id === trendRow.dataset_id && m.series_id === id)?.nisp_label ?? id;
const onStructure = family === "income_structure";
const sel = html`<select class="nisp-select">
<option value="${structureOption}" ${onStructure ? "selected" : ""}>
${familyLabels.income_structure}</option>
${trendSeries.map(id => html`<option value="${id}"
${!onStructure && id === activeSeries ? "selected" : ""}>${labelFor(id)}</option>`)}
</select>`;
sel.onchange = () => {
if (sel.value === structureOption) { mutable family = "income_structure"; return; }
mutable family = "trend";
mutable seriesId = sel.value;
};
return html`<label class="nisp-control"><span>Measure</span>${sel}</label>`;
})()
: row.measure_mode === "select_one"
? (() => {
const sel = html`<select class="nisp-select">
${measureOptions.map(id => html`<option value="${id}"
${id === activeSeries ? "selected" : ""}>${seriesLabel(id)}</option>`)}
</select>`;
sel.onchange = () => { mutable seriesId = sel.value; };
return html`<label class="nisp-control"><span>Measure</span>${sel}</label>`;
})()
: html`<div class="nisp-control"><span>Components</span>
<span class="nisp-control-note">${rowSeries.map(seriesLabel).join(" · ")}</span></div>`;
return html`<div class="nisp-ch-controlrow">
${measureControl}
<div class="nisp-control-note">
<strong>Source:</strong> ${vintage?.source_agency ?? "BEA"},
${vintage?.source_release_date ?? build?.source_vintage_ids ?? "n/a"} ·
<strong>Dataset:</strong> ${row.dataset_id}</div>
</div>`;
}// ---- Renderers -------------------------------------------------------
// Only these renderer_id values are implemented on the Website side. Rows
// whose renderer applies an analytical transformation belong to the chart
// workstream, so the page reports them as pending rather than implementing
// the economics here (contract sections 4.3 and 14.3).
websiteRenderers = ["income_structure_area", "timeseries_level_line",
"timeseries_ratio_line", "cross_section_bar",
"timeseries_growth_line", "historical_distribution_boxplot"] // the last two plot values supplied in assets/data/charts/function chartView() {
if (!row) return html`<p class="nisp-cat-empty">This combination is not a published
EARL chart product.</p>`;
const ink = "#1f3347";
const panel = document.querySelector(".nisp-ch-chart");
const pageWidth = width;
const w = Math.max(420, Math.round(panel ? panel.clientWidth : pageWidth) - 32);
if (!websiteRenderers.includes(row.renderer_id)) {
return html`<div class="nisp-pending-chart">
<div class="nisp-ch-title">${chartTitle}</div>
<p><strong>Awaiting chart renderer <code>${row.renderer_id}</code>.</strong></p>
<p>This product applies the <code>${row.transformation_id}</code> transformation,
which belongs to the chart workstream. The Website will dispatch to the renderer
once the registry row moves to <em>implemented</em>; it does not compute the
transformation itself.</p>
<p class="nisp-control-note">Registry row ${row.chart_id} ·
status ${row.production_status} · priority ${row.launch_priority}</p>
</div>`;
}
// Cross-sectional bars at one common reference period
if (row.renderer_id === "cross_section_bar") {
if (!commonPeriod) return html`<p class="nisp-cat-empty">No period is published for
every peer industry on this measure, so there is no valid common reference period.</p>`;
const pct = isRatio(activeSeries);
return html`<div>
<div class="nisp-ch-title">${chartTitle} · ${fmtPeriod(commonPeriod)}</div>
${Plot.plot({
width: w, height: 420, marginLeft: 190, marginRight: 24,
x: {label: pct ? `${seriesLabel(activeSeries)} (%)` : `${seriesLabel(activeSeries)} ($ billions)`,
labelArrow: "none", grid: true, tickFormat: pct ? ".0%" : ",.0f"},
y: {label: null},
marks: [
Plot.barX(crossSectionRows, {y: "label", x: "v", fill: ink, sort: {y: "-x"}}),
Plot.ruleX([0]),
Plot.tip(crossSectionRows, Plot.pointerY({y: "label", x: "v",
title: d => `${d.label}\n${seriesLabel(activeSeries)}: ${fmtValue(d.v, pct)}`}))
]
})}
<p class="nisp-ch-note">All ${crossSectionRows.length} peer industries at the latest
period published for every one of them. Parent aggregates are excluded.</p>
</div>`;
}
// Income structure: the published components, stacked under the published total
if (row.renderer_id === "income_structure_area") {
const [totalId, ...partIds] = rowSeries;
// The total can extend further back than the cost/revenue components
// (National quarterly: GVA from 1947 but GO and II only from 2005).
// Never render a missing stacked component as zero: use common valid coverage.
const commonPeriods = periods.filter(p => valueAt(totalId, p) != null &&
partIds.every(id => valueAt(id, p) != null));
const parts = commonPeriods.flatMap(p => partIds.map(id =>
({period: p, part: seriesLabel(id), v: valueAt(id, p)})));
const total = commonPeriods.map(p => ({period: p, v: valueAt(totalId, p)}));
if (!total.length) return html`<p class="nisp-cat-empty">The Income Structure
components are not simultaneously available for ${entityLabel} at this frequency.
Individual published series may still be available in Trend.</p>`;
const coverageRestricted = commonPeriods.length < periods.length;
const partLabels = partIds.map(seriesLabel);
return html`<div>
<div class="nisp-ch-title">${chartTitle}</div>
${Plot.plot({
width: w, height: 420, marginLeft: 76, marginRight: 24,
x: timeAxis(commonPeriods, frequency, activeHistory, w),
y: {label: "$ billions", labelArrow: "none", grid: true, tickFormat: ",.0f"},
color: {domain: [partLabels[1], partLabels[0]],
range: ["#8fb0c9", "#2f4a63"], legend: true},
marks: [
Plot.areaY(parts, {x: "period", y: "v", fill: "part",
order: [partLabels[0], partLabels[1]], curve: "linear"}),
Plot.lineY(total, {x: "period", y: "v", stroke: ink, strokeWidth: 1.5}),
Plot.ruleY([0]),
Plot.ruleX(total, Plot.pointerX({x: "period", stroke: ink, strokeOpacity: 0.35})),
Plot.dot(total, Plot.pointerX({x: "period", y: "v", fill: ink, r: 3.5})),
Plot.tip(total, Plot.pointerX({x: "period", y: "v",
title: d => [fmtPeriod(d.period), `${seriesLabel(totalId)}: ${fmtValue(d.v)}`,
...partIds.map(id => `${seriesLabel(id)}: ${fmtValue(valueAt(id, d.period))}`)
].join("\n")}))
]
})}
<p class="nisp-ch-note">${partLabels.join(" + ")} = ${seriesLabel(totalId)},
as published. The line is the published total, not a sum computed here.
${coverageRestricted ? `The stacked structure starts at ${fmtPeriod(commonPeriods[0])},
the first available period in this view with all components and the total; earlier
individual observations remain available in Trend.` : ""}</p>
</div>`;
}
// Growth and Distribution plot values supplied by the chart workstream (assets/data/charts/<chart_id>.json),
// computed there with the chartbook's rules; the Website only selects, slices and formats them.
if (chartDataRenderers.includes(row.renderer_id)) {
if (!chartData) return html`<p class="nisp-cat-empty">The values for this chart are not published.</p>`;
if (!chartDataCurrent) return html`<p class="nisp-cat-empty">The values for this chart were built from a
different data build than the one published, so they are not shown.</p>`;
const per = frequency === "annual" ? "year" : "quarter";
const unit = chartData.value_unit;
// Growth through time: one entity, one measure, gaps stay gaps
if (row.renderer_id === "timeseries_growth_line") {
const s = chartData.data?.[activeSeries]?.[activeEntity];
const all = s ? s.periods.map((p, i) => ({period: p, v: s.values[i]})) : [];
const pts = Number.isFinite(periodCount) ? all.slice(-periodCount) : all;
const shown = pts.filter(d => d.v != null);
if (!shown.length) return html`<p class="nisp-cat-empty">${seriesLabel(activeSeries)} growth is not
published for ${entityLabel} at this frequency.</p>`;
const [lo, hi] = d3.extent(shown, d => d.v);
const tickPct = hi - lo < 0.06 ? ".1%" : ".0%"; // a narrow range has ticks below one point: never round them into repeated labels
return html`<div>
<div class="nisp-ch-title">${chartTitle}</div>
${Plot.plot({
width: w, height: 420, marginLeft: 76, marginRight: 24,
x: timeAxis(pts.map(d => d.period), frequency, activeHistory, w),
y: {label: `Change from previous ${per} (%)`, labelArrow: "none",
grid: true, tickFormat: tickPct},
marks: [
Plot.ruleY([0]),
Plot.lineY(pts, {x: "period", y: "v", stroke: ink, strokeWidth: 2}),
Plot.ruleX(shown, Plot.pointerX({x: "period", stroke: ink, strokeOpacity: 0.35})),
Plot.dot(shown, Plot.pointerX({x: "period", y: "v", fill: ink, r: 3.5})),
Plot.tip(shown, Plot.pointerX({x: "period", y: "v",
title: d => `${fmtPeriod(d.period)}\n${seriesLabel(activeSeries)} change: ${fmtChange(d.v)}`}))
]
})}
<p class="nisp-ch-note">Change from the previous ${per}, computed by EARL from the published series.
A period is left blank where the previous ${per} is missing or not positive and, for earnings measures,
where its margin was below 1% of the top line, rather than showing a misleading figure.</p>
</div>`;
}
// Distribution across industries: every observation, one box per industry, red X = most recent
const d = chartData.data?.[activeSeries];
if (!d) return html`<p class="nisp-cat-empty">${seriesLabel(activeSeries)} is not published for the
industry comparison.</p>`;
const nameOf = id => entityMeta.find(e => e.entity_id === id)?.entity_label ?? id;
const lastIdx = Math.max(...d.order.map(id => { const p = d.sample[id].periods; return p.length ? periodIndex(p[p.length - 1]) : -Infinity; }));
const firstIdx = Number.isFinite(periodCount) ? lastIdx - periodCount + 1 : -Infinity;
const sample = d.order.flatMap(id => {
const s = d.sample[id];
const rows = s.periods.map((p, i) => ({label: nameOf(id), period: p, v: s.values[i], j: s.jitter[i]}))
.filter(r => periodIndex(r.period) >= firstIdx);
if (rows.length) rows[rows.length - 1].latest = true;
return rows;
});
if (!sample.length) return html`<p class="nisp-cat-empty">No observations are published for this history.</p>`;
const latest = sample.filter(r => r.latest);
const pctAxis = unit !== "points";
const [xlo, xhi] = d3.extent(sample, r => r.v);
const tickX = pctAxis ? (xhi - xlo < 0.06 ? ".1%" : ".0%")
: (d => d === 0 ? "0" : d3.format(xhi - xlo < 6 ? "+.1f" : "+.0f")(d));
// The supplied jitter (seeded, deterministic) spreads each industry's dots across its band. Plot's dy is one offset
// per mark, so the dots are drawn in nine groups, one per offset.
const jmax = d3.max(sample, r => Math.abs(r.j)) || 1;
const jbin = r => Math.round(r.j / jmax * 4);
// Decorative Tukey whisker caps. Use the same non-outlier convention
// as Plot.boxX (1.5 * IQR); these do not transform the chart data.
const whiskerCaps = d.order.flatMap(id => {
const label = nameOf(id);
const v = sample.filter(r => r.label === label && Number.isFinite(r.v))
.map(r => r.v).sort(d3.ascending);
if (!v.length) return [];
const q1 = d3.quantileSorted(v, 0.25);
const q3 = d3.quantileSorted(v, 0.75);
const iqr = q3 - q1;
const within = v.filter(x => x >= q1 - 1.5 * iqr && x <= q3 + 1.5 * iqr);
return within.length ? [{label, v: within[0]}, {label, v: within[within.length - 1]}] : [];
});
return html`<div>
<div class="nisp-ch-title">${chartTitle}</div>
${Plot.plot({
width: w, height: 600, marginLeft: 190, marginRight: 24,
x: {label: pctAxis ? `Change from previous ${per} (%)` : `Change from previous ${per} (percentage points)`,
labelArrow: "none", grid: true, tickFormat: tickX},
y: {label: null, domain: d.order.map(nameOf).reverse()},
marks: [
// Dashed neutral zero reference, visible without obscuring the boxes.
Plot.ruleX([0], {stroke: "#61758a", strokeOpacity: 0.9,
strokeWidth: 1.5, strokeDasharray: "5 4"}),
...d3.range(-4, 5).map(b => Plot.dot(sample.filter(r => jbin(r) === b),
{x: "v", y: "label", dy: b * 1.7, r: 1.8, fill: "#8fb0c9", fillOpacity: 0.35})),
// Filled IQR boxes and a stronger median distinguish the distribution
// from the individual dots, including when its central spread is narrow.
Plot.boxX(sample, {x: "v", y: "label", stroke: ink, strokeWidth: 1.8,
fill: "#b3cede", fillOpacity: 0.88, r: 2}),
Plot.tickX(whiskerCaps, {x: "v", y: "label", stroke: ink,
strokeOpacity: 0.8, strokeWidth: 1.25, insetTop: 12, insetBottom: 12}),
Plot.dot(latest, {x: "v", y: "label", symbol: "times", stroke: "#b03a2e", strokeWidth: 2.2, r: 7}),
Plot.tip(sample, Plot.pointer({x: "v", y: "label",
title: r => `${r.label}\n${fmtPeriod(r.period)}\n${seriesLabel(activeSeries)} change: ${fmtChange(r.v, unit)}${r.latest ? "\nMost recent observation" : ""}`}))
]
})}
<p class="nisp-ch-note">Pale dots show each ${per}'s change; the filled box spans the
middle 50% (interquartile range), its central mark is the median, and the red X is the latest observation.
The dashed vertical line marks zero change; short end caps mark the non-outlier whiskers.
Extreme observations remain included on the shared horizontal scale. Industries are ordered by
${seriesLabel(activeSeries)} in ${fmtPeriod(d.reference_period)}, the latest period published for all
${d.order.length} of them. Changes are computed by EARL from the published series.</p>
</div>`;
}
// Single measure through time, level or ratio
const pct = isRatio(activeSeries);
const series = periods.map(p => ({period: p, v: valueAt(activeSeries, p)}))
.filter(d => d.v != null);
if (!series.length) return html`<p class="nisp-cat-empty">${seriesLabel(activeSeries)} is
not published for ${entityLabel} at this frequency.</p>`;
return html`<div>
<div class="nisp-ch-title">${chartTitle}</div>
${Plot.plot({
width: w, height: 420, marginLeft: 76, marginRight: 24,
x: timeAxis(periods, frequency, activeHistory, w),
y: {label: pct ? `${seriesLabel(activeSeries)} (%)` : "$ billions",
labelArrow: "none", grid: true,
tickFormat: pct ? ".0%" : ",.0f"},
marks: [
pct ? null : Plot.areaY(series, {x: "period", y: "v", fill: "#dce7f0"}),
Plot.lineY(series, {x: "period", y: "v", stroke: ink, strokeWidth: 2}),
Plot.ruleY([0]),
Plot.ruleX(series, Plot.pointerX({x: "period", stroke: ink, strokeOpacity: 0.35})),
Plot.dot(series, Plot.pointerX({x: "period", y: "v", fill: ink, r: 3.5})),
Plot.tip(series, Plot.pointerX({x: "period", y: "v",
title: d => `${fmtPeriod(d.period)}\n${seriesLabel(activeSeries)}: ${fmtValue(d.v, pct)}`}))
].filter(Boolean)
})}
<p class="nisp-ch-note">${pct
? `${seriesLabel(activeSeries)} is shown as a percentage of its published denominator.`
: `Dollar values are displayed in billions, scaled from the canonical published data.`}
Earlier periods are shown only where the selected series is available.</p>
</div>`;
}function sourceNote() {
if (!row) return html`<div></div>`;
const shown = row.measure_mode === "select_one" ? [activeSeries] : rowSeries;
const tables = new Map();
for (const id of shown) {
const meta = seriesMeta.find(s => s.dataset_id === row.dataset_id && s.series_id === id);
if (meta?.source_dataset) {
const key = meta.source_dataset;
if (!tables.has(key)) tables.set(key, new Set());
tables.get(key).add(meta.source_table_label || meta.source_table_id);
}
}
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 chart. Newer BEA estimates
may supersede some observations; this presentation retains the stated build for reproducibility.</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> ${row.dataset_id} ·
<strong>Chart:</strong> <code>${row.chart_id}</code> ·
<strong>Build:</strong> <code>${build?.build_id ?? "n/a"}</code></p>
${tables.size ? html`<p><strong>Underlying BEA tables</strong></p>
<ul class="nisp-source-tables">
${[...tables].map(([ds, t]) => html`<li><strong>${ds}</strong>: ${[...t].sort().join(", ")}</li>`)}
</ul>` : ""}
<p>${chartDataRenderers.includes(row.renderer_id)
? `Derived changes (${row.transformation_id}) use canonical data from the same build; the Website selects and formats the computed values.`
: "Values come from the canonical published series; the Website filters and formats them without recalculating the economic data."}
<a class="nisp-source-link" href="method.html">Read EARL's methodology</a>.</p>
</div>
</details>
</section>`;
}