EARL — Economic Accounts Research Lab EARL — Economic Accounts Research Lab
  • Releases
  • Income Statements
  • Charts
  • Data
  • Method
  • About

Charts

frequencyPicker()
scopePicker()
historyPicker()
controlRow()
chartView()
familyPicker()
sourceNote()
// ---- 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")
// ---- Canonical published data ----------------------------------------
seriesMeta = d3.csv("assets/data/current/series-metadata.csv")
entityMeta = d3.csv("assets/data/current/entity-metadata.csv")
buildMeta = d3.csv("assets/data/current/build-metadata.csv")
vintages = d3.csv("assets/data/current/source-vintages.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"
})
// Public family labels, per section 7 of the contract
familyLabels = ({
  income_structure: "Income Structure",
  trend: "Trend",
  growth: "Growth",
  margins: "Margins & Ratios",
  by_industry: "Industry Comparison",
  distribution: "Distribution"
})
familyOrder = ["income_structure", "trend", "growth", "margins", "by_industry", "distribution"]
scopeLabels = ({national: "National", corporate: "Corporate", industry: "Industry"})
// ---- Landing state: National / Annual / Income Structure / 5Y ---------
mutable family = "income_structure"
mutable scope = "national"
mutable frequency = "annual"
mutable history = null
mutable seriesId = null
mutable entityId = null
families = familyOrder.filter(f =>
  f !== "income_structure" &&                  // offered inside the Trend selector
  registry.some(r => r.chart_family === f))
// Only the scopes and frequencies the registry declares for this family
scopesForFamily = ["national", "corporate", "industry"]
  .filter(s => registry.some(r => r.chart_family === family && r.scope === s))
frequenciesForState = ["annual", "quarterly"]
  .filter(f => registry.some(r =>
    r.chart_family === family && r.scope === scope && r.frequency === f))
// The registry products of the selected state (one, except for Distribution)
rowsHere = registry.filter(r => r.chart_family === family && r.scope === scope &&
  r.frequency === frequency)
// 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
// The measures offered across the products of this state (the row's own series when there is only one product)
measureOptions = [...new Set(rowsHere.flatMap(r => r.series_ids.split(";").map(s => s.trim()).filter(Boolean)))]
// 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
trendRow = rowFor("trend")
structureRow = rowFor("income_structure")
structureOption = "__income_structure__"
rowSeries = row ? row.series_ids.split(";").map(s => s.trim()).filter(Boolean) : []
seriesLabel = id => seriesMeta.find(s =>
  s.dataset_id === row?.dataset_id && s.series_id === id)?.nisp_label ?? id
// The measure selector is populated from the row, never from a local list
activeSeries = {
  if (!row) return null;
  if (row.measure_mode === "fixed_set") return null;
  return rowSeries.includes(seriesId) ? seriesId : (row.default_series_id || rowSeries[0]);
}
// History options per section 13; By Industry declares NA and hides the control
historyOptions = ["5Y", "10Y", "All"]
showHistory = !!row && row.default_history !== "NA" && row.chart_family !== "by_industry"
activeHistory = {
  if (!row) return "5Y";
  const wanted = history ?? row.default_history;
  return historyOptions.includes(wanted) ? wanted : "5Y";
}
// 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")];
}
// Peers for cross-sectional charts: one hierarchy level only, so parents are
// never ranked alongside their descendants (registry note on by_industry)
peerSectors = industrySectors
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];
}
entityLabel = activeEntity
  ? (entityMeta.find(e => e.entity_id === activeEntity)?.entity_label ?? activeEntity)
  : "Industries"
values = row ? await d3.csv(valueFiles[row.dataset_id]) : []
// 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"]
chartData = row && chartDataRenderers.includes(row.renderer_id)
  ? await d3.json(`assets/data/charts/${row.chart_id}.json`).catch(() => null)
  : null
// The supplied values must come from the very data build the page publishes; otherwise they are not shown
chartDataCurrent = !!chartData &&
  chartData.provenance?.build_id === build?.build_id &&
  chartData.provenance?.file_sha256 === build?.sha256
entityRows = activeEntity ? values.filter(d => d.entity_id === activeEntity) : []
allPeriods = [...new Set(entityRows.map(d => d.period))].sort()
periodCount = {
  const years = {"5Y": 5, "10Y": 10, "All": Infinity}[activeHistory];
  if (!Number.isFinite(years)) return Infinity;
  return frequency === "annual" ? years : years * 4;
}
periods = Number.isFinite(periodCount) ? allPeriods.slice(-periodCount) : allPeriods
valueIndex = {
  const m = new Map();
  for (const d of entityRows) m.set(`${d.series_id}|${d.period}`, d.value);
  return m;
}
// 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;
}
isRatio = id => seriesMeta.find(s =>
  s.dataset_id === row?.dataset_id && s.series_id === id)?.unit_id === "ratio"
// 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);
}
build = row ? buildMeta.find(b => b.dataset_id === row.dataset_id) : null
vintage = vintages.find(v => v.source_vintage_id === build?.source_vintage_ids)
fmtPeriod = p => p && p.includes("Q") ? p.replace("Q", " Q") : p
fmtValue = (v, pct = false) => v == null ? "n/a"
  : pct ? d3.format(".1%")(v) : `$${d3.format(",.1f")(v)}bn`
// A change from the previous period: a fraction ("+2.1%") or percentage points ("+0.3 pp")
fmtChange = (v, unit = "fraction") => v == null ? "n/a"
  : unit === "points" ? `${v < 0 ? "−" : "+"}${Math.abs(v).toFixed(1)} pp`
  : d3.format("+.1%")(v)
// "2026Q2" -> 8106, "2025" -> 2025 (consecutive periods differ by one within a frequency)
periodIndex = p => p.includes("Q") ? +p.slice(0, 4) * 4 + (+p.slice(5) - 1) : +p
// 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)};
}
chartTitle = {
  if (!row) return "";
  return row.chart_title_template
    .replace("{measure_label}", activeSeries ? seriesLabel(activeSeries) : "")
    .replace("{entity_label}", entityLabel)
    .trim();
}
// ---- 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>`;
}