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

Income Statements

viewof timeline = rangeSlider(["Recent", "5 year", "10 year", "All"], "5 year")
viewof frequency = slider(["Annual", "Quarterly"], "Annual")
filterTree()
metaRow()
statement()
sourceNote()
// ---- 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")
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")
// 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"
})
mutable scope = "National"
mutable industryId = null
family = ({National: "NIS", Corporate: "CIS", Industry: "IIS"})[scope] +
  (frequency === "Annual" ? "-A" : "-Q")
entityId = {
  if (scope === "National") return "national";
  if (scope === "Corporate") return "corporate";
  const ids = majorSectors.map(s => s.id);
  return ids.includes(industryId) ? industryId : ids[0];
}
// 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")
  ];
}
entityLabel = entityMeta.find(e => e.entity_id === entityId)?.entity_label ?? entityId
values = d3.csv(valueFiles[family])
entityRows = values.filter(d => d.entity_id === entityId)
allPeriods = [...new Set(entityRows.map(d => d.period))].sort()
// Periods shown per timeline step, counted in years
periodCount = {
  const years = {"Recent": 2, "5 year": 5, "10 year": 10, "All": Infinity}[timeline];
  if (!Number.isFinite(years)) return Infinity;
  return frequency === "Annual" ? years : years * 4;
}
// Newest first: take the latest periods, then show them left to right
periods = (Number.isFinite(periodCount) ? allPeriods.slice(-periodCount) : allPeriods)
  .slice().reverse()
// series_id + period -> published value
valueIndex = {
  const m = new Map();
  for (const d of entityRows) m.set(`${d.series_id}|${d.period}`, d.value);
  return m;
}
seriesIndex = {
  const m = new Map();
  for (const s of seriesMeta) m.set(`${s.dataset_id}|${s.series_id}`, s);
  return m;
}
// 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;
}
// Release provenance for the statement currently displayed
build = buildMeta.find(b => b.dataset_id === family) ?? buildMeta[0]
vintage = vintages.find(v => v.source_vintage_id === build?.source_vintage_ids)
// 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()
  }));
}
// The statement is in $ millions, so the rows that are not say so inline
unitHint = unit => unit === "thousand_fte" ? "(FTE, thousands)"
  : unit === "usd_per_fte" ? "($ / FTE)" : ""
fmtPeriod = p => p.includes("Q") ? p.replace("Q", " Q") : p
// 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 / 1000
isMoney = unit => unit.startsWith("usd_millions")
fmtValue = (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;
}
// SheetJS, for writing the displayed statement to a workbook in the browser
XLSX = require("xlsx@0.18.5/dist/xlsx.full.min.js")
// 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">&nbsp;${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>`;
}