Documentation

Adult and Young Adult Substance Use Dashboard

Significance Tests

sigData = FileAttachment("chapter2_sigtests.csv").csv({ typed: true })

sigsDrug = sigData.filter(
  ({ drug }) => drug === select
)

significance = sigsDrug.filter(({ time }) => time === radio[0].time)
significance

Significance Legend in Graph

fmtChange = d => (d == null || isNaN(d) ? "." : d.toFixed(1))
fmtSig = d => (d == null ? "N/A" : d)

// Baseline-aware significance marker. A trend window only exists if its
// baseline year is present; parseNum() returns null for null / "" / "."
// (and never NaN). When the baseline (OneYear1 / FiveYear1 / TenYear1) is
// missing, show "N/A" regardless of what sig_* holds. Otherwise show the
// marker, or "N/A" if it too is empty. This guards the legend/table even if
// a stray "n.s." ever slips back into the source data.
sigMark = (row, baseKey, sigKey) => {
  if (row == null) return "N/A";
  if (parseNum(row[baseKey]) == null) return "N/A";
  const sv = row[sigKey];
  return (sv == null || sv === "" || sv === ".") ? "N/A" : sv;
}

sigLegend1 = html`<style>
    caption {
      font-size: 9px;
    }

    table {
      margin-bottom: 0.5rem;
    }

    .small td {
        font-size: 9px;
        color: #000066;
    }

</style>
<table class="small" style="width: 250px; ">
  <caption style="color: #000066;"> <b><u>2024 Trends</b></u> </caption>
  <tr>
    <td>Ages 19-30</td>
    <td>1-Year Change</td>
    <td>${fmtChange(significance[0]?.oneyrchange)}</td>
    <td>${sigMark(significance[0], "OneYear1", "sig_oneyr")}</td>
  </tr>
  <tr>
    <td> </td>
    <td>5-Year Change</td>
    <td>${fmtChange(significance[0]?.fiveyrchange)}</td>
    <td>${sigMark(significance[0], "FiveYear1", "sig_fiveyr")}</td>
  </tr>
  <tr style="background-color: white;">
    <td></td>
    <td>10-Year Change</td>
    <td>${fmtChange(significance[0]?.tenyrchange)}</td>
    <td>${sigMark(significance[0], "TenYear1", "sig_tenyr")}</td>
  </tr>
</table>`


sigLegend2 = html`<style>
    caption {
      font-size: 9px;
    }

    table {
      margin-bottom: 0.5rem;
    }

   .small td {
        font-size: 9px;
        color: #000066;
    }

</style>
<table class="small" style="width: 250px; ">
  <caption style="color: #000066;"> <b><u>2024 Trends</b></u> </caption>
  <tr>
    <td>Ages 19-30</td>
    <td>1-Year Change</td>
    <td>${fmtChange(significance[0]?.oneyrchange)}</td>
    <td>${sigMark(significance[0], "OneYear1", "sig_oneyr")}</td>
  </tr>
  <tr>
    <td> </td>
    <td>5-Year Change</td>
    <td>${fmtChange(significance[0]?.fiveyrchange)}</td>
    <td>${sigMark(significance[0], "FiveYear1", "sig_fiveyr")}</td>
  </tr>
  <tr style="background-color: white;">
    <td></td>
    <td>10-Year Change</td>
    <td>${fmtChange(significance[0]?.tenyrchange)}</td>
    <td>${sigMark(significance[0], "TenYear1", "sig_tenyr")}</td>
  </tr>
  <tr>
    <td>Ages 35-50</td>
    <td>1-Year Change</td>
    <td>${fmtChange(significance[1]?.oneyrchange)}</td>
    <td>${sigMark(significance[1], "OneYear1", "sig_oneyr")}</td>
  </tr>
  <tr>
    <td> </td>
    <td>5-Year Change</td>
    <td>${fmtChange(significance[1]?.fiveyrchange)}</td>
    <td>${sigMark(significance[1], "FiveYear1", "sig_fiveyr")}</td>
  </tr>
  <tr>
    <td></td>
    <td>10-Year Change</td>
    <td>${fmtChange(significance[1]?.tenyrchange)}</td>
    <td>${sigMark(significance[1], "TenYear1", "sig_tenyr")}</td>
  </tr>
   <tr>
    <td>Ages 55-65</td>
    <td>1-Year Change</td>
    <td>${fmtChange(significance[2]?.oneyrchange)}</td>
    <td>${sigMark(significance[2], "OneYear1", "sig_oneyr")}</td>
  </tr>
  <tr>
    <td> </td>
    <td>5-Year Change</td>
    <td>${fmtChange(significance[2]?.fiveyrchange)}</td>
    <td>${sigMark(significance[2], "FiveYear1", "sig_fiveyr")}</td>
  </tr>
  <tr>
    <td></td>
    <td>10-Year Change</td>
    <td>${fmtChange(significance[2]?.tenyrchange)}</td>
    <td>${sigMark(significance[2], "TenYear1", "sig_tenyr")}</td>
  </tr>
</table>`

sigLegend = significance[1]?.oneyrchange === undefined ? sigLegend1 : sigLegend2

legendPlace = {if (radio[0]?.drug == "Alcohol" && radio[0]?.time == "12 Month"){
  return "bottom: 60px; right: 50px;";
} else if (radio[0]?.drug == "Alcohol" && radio[0]?.time == "Lifetime") {
  return "bottom: 60px; right: 50px;";
} else if (radio[0]?.drug == "Alcohol" && radio[0]?.time == "30 Day") {
  return "bottom: 60px; right: 50px;";
} else {
  return "top: 50px; right: 50px;";
}
}

legendPlace

Table Long Format

longTable = Inputs.table(tablesTime, {
  columns: [
    "year",
    "age_18",
    "age_19_20",
    "age_21_22",
    "age_23_24",
    "age_25_26",
    "age_27_28",
    "age_29_30",
    "age_35",
    "age_40",
    "age_45",
    "age_50",
    "age_55",
    "age_60",
    "age_65"
  ],
  header: {
    year: "Year",
    age_18: "Age 18",
    age_19_20: "Ages 19-20",
    age_21_22: "Ages 21-22",
    age_23_24: "Ages 23-24",
    age_25_26: "Ages 25-26",
    age_27_28: "Ages 27-28",
    age_29_30: "Ages 29-30",
    age_35: "Age 35",
    age_40: "Age 40",
    age_45: "Age 45",
    age_50: "Age 50",
    age_55: "Age 55",
    age_60: "Age 60",
    age_65: "Age 65"
  },
    format: {
    year: d => html`<b>${d}</b>`,
    age_18: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_19_20: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_21_22: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_23_24: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_25_26: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_27_28: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_29_30: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_35: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_40: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_45: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_50: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_55: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_60: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1),
    age_65: d=> (d == null || isNaN(d)) ? "." : d.toFixed(1)
  },
  rows: 75,
  width: 1200
})

Accordion Buttons

accordionButtons = 
html`
<div style="width: 1224px; max-width: 100%; margin-top: -0.5rem; border-top: 1px solid var(--bs-border-color, #dee2e6);" class="accordion accordion-flush" id="accordionFlushExample">
  <div class="accordion-item">
    <h2 class="accordion-header" id="flush-headingOne">
      <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#flush-collapseOne" aria-expanded="false" aria-controls="flush-collapseOne">
        ${radio[0].drug}: Trends in ${radio[0].time} Prevalence Table
      </button>
    </h2>
    <div id="flush-collapseOne" class="accordion-collapse collapse" aria-labelledby="flush-headingOne" data-bs-parent="#accordionFlushExample">
      <div class="accordion-body">
              <div class="center"><div>${longTable}</div></div>
    </div>
  </div>
</div>`

Citation

citation = html`<div style="max-width: 750px;"><p style="font-size: small;">Suggested citation: Patrick, M. E., Miech, R. A., O'Malley, P. M., Jager, J. O., & Jang, J. B. (2026). Monitoring the Future Longitudinal Panel Study annual report: National data on substance use among adults ages 19 to 65, 1976–2025. Monitoring the Future Monograph Series. Ann Arbor, MI: Institute for Social Research, University of Michigan. <a href="https://monitoringthefuture.org/wp-content/uploads/2026/07/mtfpanel2026.pdf" target="_blank" rel="noopener noreferrer">MTF Panel Study Annual Report</a></p></div>`

Footnotes

footnotesData = FileAttachment("Footnotes.csv").csv({ typed: true })

footnotesFiltered = footnotesData.filter(
  ({ drug }) => drug.toLocaleLowerCase() === select.toLocaleLowerCase()
)

 function checkFootnote(d) {
  if (d.length == 0 ) {
  return html`<div></div>`;
} else if (d.length == 1) {
  return html`<div style="max-width: 750px;"><p style="font-size: small;"><sup>${footnotesFiltered[0]?.superscript}</sup>${footnotesFiltered[0]?.footnote}</p></div>`;
} else {
  return html`<div style="max-width: 750px;"><p style="font-size: small;"><sup>${footnotesFiltered[0]?.superscript}</sup>${footnotesFiltered[0]?.footnote}<br><sup>${footnotesFiltered[1]?.superscript}</sup>${footnotesFiltered[1]?.footnote}</p></div>`;
}
}

footnotes = checkFootnote(footnotesFiltered)

html`${footnotes}`

Table Data

tables = FileAttachment("final_table_data.csv").csv({ typed: true })

// Missing values arrive as "" or "." (and sometimes null). Anything that
// doesn't coerce to a finite number becomes null, never NaN -- NaN survives
// `!= null` checks and silently poisons extents, min/max, and toFixed().
parseNum = d => {
  if (d == null || d === "") return null;
  const n = +d;
  return Number.isFinite(n) ? n : null;
}

tablesFormatted = {
  const tables2 = tables.map(
    ({ time, drug, year, age_18, age_19_20, age_21_22, age_23_24, age_25_26, age_27_28, age_29_30, age_35, age_40, age_45, age_50, age_55, age_60, age_65
 }) => ({
      time: time,
      drug: drug,
      year: String(year),
      age_18: parseNum(age_18),
      age_19_20: parseNum(age_19_20),
      age_21_22: parseNum(age_21_22),
      age_23_24: parseNum(age_23_24),
      age_25_26: parseNum(age_25_26),
      age_27_28: parseNum(age_27_28),
      age_29_30: parseNum(age_29_30),
      age_35: parseNum(age_35),
      age_40: parseNum(age_40),
      age_45: parseNum(age_45),
      age_50: parseNum(age_50),
      age_55: parseNum(age_55),
      age_60: parseNum(age_60),
      age_65: parseNum(age_65)
    })
  );

  return tables2.filter(
    ({ drug }) => drug?.toLocaleLowerCase() === select?.toLocaleLowerCase()
  );
}


tablesTime = tablesFormatted.filter(({ time }) => time === radio[0].time)

tablesTime

SUD Stacked Bar Data and Controls

sudRaw = FileAttachment("sud_stacked_data.csv").csv({ typed: true })

sudSubstances = [...new Set(sudRaw.map(d => d.substance))]

viewof sudSubstance = Inputs.select(sudSubstances, {
  value: "Cannabis Use Disorder",
  label: "Select SUD Measure:",
  width: 220
})

viewof sudAgeGroup = Inputs.radio(["Age 35", "Ages 40-50", "Ages 55-65"], {
  value: "Ages 40-50",
  label: "Age Group:"
})

// Rank pins the stack order (0 = bottom), matching the Excel column charts:
// Disordered on the bottom, Nondisordered in the middle, Abstainer on top.
sudRank = ({ Disordered: 0, Nondisordered: 1, Abstainer: 2 })

sudData = sudRaw
  .filter(d => d.substance === sudSubstance && d.age_group === sudAgeGroup)
  .map(d => ({
    substance: d.substance,
    age_group: d.age_group,
    year: String(d.year),
    category: d.category,
    percentage: parseNum(d.percentage),
    rank: sudRank[d.category]
  }))

sudData

SUD Stacked Bar Plot

sudColorDomain = ["Abstainer", "Nondisordered", "Disordered"]
sudColorRange  = ["#59bbeb", "#d1a438", "#6ac464"]

sudPlot = addTooltips(
  Plot.plot({
    ariaLabel: `100% stacked bar chart of ${sudSubstance} composition among ${sudAgeGroup}, by year`,
    width: 700,
    height: 450,
    marginBottom: 45,
    marginLeft: 55,
    style: { overflow: "visible", fontSize: 12 },
    x: { label: "Year", type: "band", tickFormat: d => d },
    y: { label: "Percentage (%)", labelAnchor: "center", domain: [0, 100], grid: true },
    color: {
      domain: sudColorDomain,
      range: sudColorRange,
      legend: true,
      swatchSize: 18
    },
    marks: [
      // order: "rank" -> Disordered (0) on the bottom, Abstainer (2) on top.
      Plot.barY(sudData, Plot.stackY({ order: "rank" }, {
        x: "year",
        y: "percentage",
        fill: "category",
        inset: 0.5,
        title: d => `${d.category}\n${d.year}: ${d.percentage == null ? "No data" : d.percentage.toFixed(1) + "%"}`
      })),
      Plot.ruleY([0])
    ]
  }),
  { fill: "category" }
)

SUD Combined Table

sudEstimateYears = [...new Set(sudData.map(d => d.year))].sort()

sudSig = FileAttachment("sud_sigtests.csv").csv({ typed: true })

sudSigRows = sudSig.filter(
  d => d.substance === sudSubstance && d.age_group === sudAgeGroup
)

// Empty change -> "."; empty sig -> "N/A"; keeps "n.s." and "p<.xx" verbatim.
sudFmtChange = d => {
  const n = parseNum(d);
  return n == null ? "." : n.toFixed(1);
}
sudFmtSig = d => (d == null || d === "") ? "N/A" : d

sudTrendCols = ["1-Yr Change", "1-Yr Sig.", "5-Yr Change", "5-Yr Sig.", "10-Yr Change", "10-Yr Sig."]

// One row per group (Abstainer / Nondisordered / Disordered); columns are the
// per-year percentages followed by the trend / p-value labels.
sudCombinedRows = ["Abstainer", "Nondisordered", "Disordered"].map(cat => {
  const row = { Group: cat };
  sudEstimateYears.forEach(y => {
    const m = sudData.find(d => d.year === y && d.category === cat);
    row[y] = m ? m.percentage : null;
  });
  const sig = sudSigRows.find(r => r.category === cat) || {};
  row["1-Yr Change"]  = sig.oneyr_change;
  row["1-Yr Sig."]    = sig.oneyr_sig;
  row["5-Yr Change"]  = sig.fiveyr_change;
  row["5-Yr Sig."]    = sig.fiveyr_sig;
  row["10-Yr Change"] = sig.tenyr_change;
  row["10-Yr Sig."]   = sig.tenyr_sig;
  return row;
})

sudCombinedColumns = ["Group", ...sudEstimateYears, ...sudTrendCols]

sudCombinedFormat = ({
  Group: d => html`<b>${d}</b>`,
  "1-Yr Change": sudFmtChange,
  "1-Yr Sig.": sudFmtSig,
  "5-Yr Change": sudFmtChange,
  "5-Yr Sig.": sudFmtSig,
  "10-Yr Change": sudFmtChange,
  "10-Yr Sig.": sudFmtSig,
  ...Object.fromEntries(
    sudEstimateYears.map(y => [y, d => (d == null || isNaN(d)) ? "." : d.toFixed(1)])
  )
})

// Per-column widths; their sum drives the accordion width so the dropdown
// button lines up with the table underneath it.
sudColWidths = ({
  Group: 120,
  ...Object.fromEntries(sudEstimateYears.map(y => [y, 55])),
  ...Object.fromEntries(sudTrendCols.map(c => [c, 82]))
})

sudTableWidth = Object.values(sudColWidths).reduce((a, b) => a + b, 0)

sudCombinedTable = Inputs.table(sudCombinedRows, {
  columns: sudCombinedColumns,
  format: sudCombinedFormat,
  width: sudColWidths,
  rows: 5
})

SUD Table Accordion

// Single dropdown holding the combined estimates + significance table. Its
// width tracks the table (sudTableWidth) so the button matches the table.
sudTablesAccordion = html`
<div style="width: ${sudTableWidth + 24}px; max-width: 100%;" class="accordion accordion-flush" id="accordionSud">
  <div class="accordion-item">
    <h2 class="accordion-header" id="sud-headingCombined">
      <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sud-collapseCombined" aria-expanded="false" aria-controls="sud-collapseCombined">
        ${sudSubstance}: ${sudAgeGroup} Estimates and Significance
      </button>
    </h2>
    <div id="sud-collapseCombined" class="accordion-collapse collapse" aria-labelledby="sud-headingCombined" data-bs-parent="#accordionSud">
      <div class="accordion-body">
        <div class="center"><div>${sudCombinedTable}</div></div>
      </div>
    </div>
  </div>
</div>`

Plot Data

parser = d3.timeParse("%Y")

format = d3.format(".4")

raw = FileAttachment("final_graph2_data.csv").csv({ typed: true })
rawAlphabetical = raw.sort((a, b) => {
  if (a.drug && b.drug) {
    return a.drug.localeCompare(b.drug);
  } else {
    return 0; // Preserve the order if 'name' is missing
  }
});

rawAlphabetical
prevalence = rawAlphabetical.filter(thing => thing.drug != null)


data = {
  const subset = prevalence.map(
    ({ year, drug, estimate, age, time, flag }) => ({
      year: parser(year),
      year2: format(year),
      estimate: parseNum(estimate),
      drug: drug,
      age: age,
      time: time,
      flag: flag
    })
  );

  const filtered = subset.filter(thing => 
  thing.drug?.toLocaleLowerCase() === select?.toLocaleLowerCase()
);

  return filtered;
}

data
radio

Radio Buttons

viewof radio = {
  const values = d3.group(data, (d) => d.time);
  return Inputs.radio(values, {
    key: values.has("12 Month")
      ? "12 Month"
      : values.keys().next().value
  });
}

Zoom-in Button

function buttonToggle({ 
  onText = "Zoom in",
  offText = "Zoom out",
  value = 0,
  click = (value, clicks) => clicks
} = {}) {
  let text = onText,
    ml = html`<button type="button" class="btn btn-outline-primary" style="min-height: 44px; min-width: 44px; padding: 0.5em 1em;">${text}</button>`,
    clicks = 0,
    v = value;

  ml.value = v;
  ml.onclick = () => {
    v = click(v, ++clicks);

    ml.value = v;
    ml.innerHTML = clicks % 2 === 0 ? onText : offText;
  };
  return ml;
}

max = Math.max(...legendData.filter((o) => Number.isFinite(o.estimate)).map((o) => o.estimate))
min = Math.min(...legendData.filter((o) => Number.isFinite(o.estimate)).map((o) => o.estimate))
yAxis = max + (max-min)

max
min
yscale = {
  if (zoomInButton % 2 == 0) {
    return [0, 100]; // If this statement is true, return this
  } else {
    return [0, yAxis]; // If the second statement is true, return this
  }
}

viewof zoomInButton = buttonToggle()

legendData
function dataToggle({ 
  onText = "Show All Age Groups",
  offText = "Show Combined Age Groups",
  value = 0,
  click = (value, clicks) => clicks
} = {}) {
  let text = onText,
    ml = html`<button type="button" class="btn btn-outline-primary" style="min-height: 44px; min-width: 44px; padding: 0.5em 1em;">${text}</button>`,
    clicks = 0,
    v = value;

  ml.value = v;
  ml.onclick = () => {
    v = click(v, ++clicks);

    ml.value = v;
    ml.innerHTML = clicks % 2 === 0 ? onText : offText;
  };
  return ml;
}


plots = {
  if (dataSwitchButton % 2 == 0) {
    return comboPlot; // If this statement is true, return this
  } else {
    return plot2; // If the second statement is true, return this
  }
}

html`<div>${plots}</div>`
viewof dataSwitchButton = dataToggle()

Horizontal Estimates and Significance Table

pivotAges = ["Age 19-30", "Age 35-50", "Age 55-65"]

pivotYears = [...new Set(radio.map(d => d.year.getFullYear()))].sort((a, b) => a - b)

pivotRows = pivotAges
  .map((age, i) => {
    const row = { age };
    pivotYears.forEach(y => {
      const match = radio.find(d => d.age === age && d.year.getFullYear() === y);
      row["_" + y] = match ? match.estimate : null;
    });
    const sig = significance[i];
    row.oneyr_change = sig?.oneyrchange ?? null;
    row.oneyr_sig = sigMark(sig, "OneYear1", "sig_oneyr");
    row.fiveyr_change = sig?.fiveyrchange ?? null;
    row.fiveyr_sig = sigMark(sig, "FiveYear1", "sig_fiveyr");
    row.tenyr_change = sig?.tenyrchange ?? null;
    row.tenyr_sig = sigMark(sig, "TenYear1", "sig_tenyr");
    return row;
  })
  .filter(row => pivotYears.some(y => row["_" + y] != null))

pivotColumns = [
  "age",
  ...pivotYears.map(y => "_" + y),
  "oneyr_change",
  "oneyr_sig",
  "fiveyr_change",
  "fiveyr_sig",
  "tenyr_change",
  "tenyr_sig"
]

pivotHeader = ({
  age: "Age Group",
  oneyr_change: "1-Yr Change",
  oneyr_sig: "1-Yr Sig.",
  fiveyr_change: "5-Yr Change",
  fiveyr_sig: "5-Yr Sig.",
  tenyr_change: "10-Yr Change",
  tenyr_sig: "10-Yr Sig.",
  ...Object.fromEntries(pivotYears.map(y => ["_" + y, String(y)]))
})

pivotFormat = ({
  age: d => d,
  oneyr_change: fmtChange,
  oneyr_sig: fmtSig,
  fiveyr_change: fmtChange,
  fiveyr_sig: fmtSig,
  tenyr_change: fmtChange,
  tenyr_sig: fmtSig,
  ...Object.fromEntries(
    pivotYears.map(y => ["_" + y, d => (d == null || isNaN(d)) ? "." : d.toFixed(1)])
  )
})

// Finds whichever descendant actually has the horizontal overflow and pins it right
scrollTableRight = (root) => {
  if (!root) return false;
  const candidates = [root, ...root.querySelectorAll("*")];
  const scroller = candidates.find(el => el.scrollWidth > el.clientWidth + 1);
  if (!scroller) return false;
  scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth;
  return true;
}

horizontalTable = Inputs.table(pivotRows, {
  columns: pivotColumns,
  header: pivotHeader,
  format: pivotFormat,
  rows: 10,
  width: 1200
})

accordionButtonsHorizontal = {
  const el = html`
<div style="width: 1224px; max-width: 100%;" class="accordion accordion-flush" id="accordionFlushHorizontal">
  <div class="accordion-item">
    <h2 class="accordion-header" id="flush-headingHorizontal">
      <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#flush-collapseHorizontal" aria-expanded="false" aria-controls="flush-collapseHorizontal">
        ${radio[0].drug}: ${radio[0].time} Estimates and Significance by Age Group
      </button>
    </h2>
    <div id="flush-collapseHorizontal" class="accordion-collapse collapse" aria-labelledby="flush-headingHorizontal" data-bs-parent="#accordionFlushHorizontal">
      <div class="accordion-body">
              <div class="center"><div>${horizontalTable}</div></div>
    </div>
  </div>
</div>`;

  const panel = el.querySelector("#flush-collapseHorizontal");

  // The table has zero width while the accordion is collapsed, so pin the
  // scrollbar to the right each time the panel actually becomes visible.
  const pinRight = () => {
    if (!scrollTableRight(horizontalTable)) {
      // Layout may not be settled yet; try again on the next frame.
      requestAnimationFrame(() => scrollTableRight(horizontalTable));
    }
  };

  // Fires as the panel starts opening, and again once the transition finishes.
  panel.addEventListener("show.bs.collapse", () => requestAnimationFrame(pinRight));
  panel.addEventListener("shown.bs.collapse", pinRight);

  // Fallback if Bootstrap's JS events aren't available: watch for the panel
  // gaining the "show" class.
  new MutationObserver(() => {
    if (panel.classList.contains("show")) requestAnimationFrame(pinRight);
  }).observe(panel, { attributes: true, attributeFilter: ["class"] });

  return el;
}

Download Data Button

downloadButton = (data, filename) => {
  
  let downloadData;
  downloadData = new Blob([d3.csvFormat(data)], { type: "text/csv" });
  const size = (downloadData.size / 1024).toFixed(0);
  const button = DOM.download(
    downloadData,
    filename,
    `Download ${filename} Dataset (~${size} KB)`
  );
  return button;
}

name = `${radio[0].drug} - ${radio[0].time}`

downloadAges = [...new Set(radio.map(d => d.age))]

// Detailed per-age columns from the long-format table (age_18 ... age_65),
// paired with friendly CSV headers.
downloadDetailCols = [
  ["age_18", "Age 18"],
  ["age_19_20", "Ages 19-20"],
  ["age_21_22", "Ages 21-22"],
  ["age_23_24", "Ages 23-24"],
  ["age_25_26", "Ages 25-26"],
  ["age_27_28", "Ages 27-28"],
  ["age_29_30", "Ages 29-30"],
  ["age_35", "Age 35"],
  ["age_40", "Age 40"],
  ["age_45", "Age 45"],
  ["age_50", "Age 50"],
  ["age_55", "Age 55"],
  ["age_60", "Age 60"],
  ["age_65", "Age 65"]
]

// Union of years present in the graph data (radio) and the long table
// (tablesTime), so no year is dropped from either source.
downloadYears = [...new Set([
  ...radio.map(d => d.year.getFullYear()),
  ...tablesTime.map(d => Number(d.year))
])].sort((a, b) => a - b)

downloadWide = downloadYears.map(y => {
  const row = { year: y };

  // Combined age-group estimates (the plotted graph data).
  downloadAges.forEach(age => {
    const match = radio.find(d => d.age === age && d.year.getFullYear() === y);
    row[age] = (match && Number.isFinite(match.estimate)) ? match.estimate : ".";
  });

  // Detailed per-age estimates from the long table.
  const detail = tablesTime.find(d => Number(d.year) === y);
  downloadDetailCols.forEach(([key, label]) => {
    const v = detail ? detail[key] : null;
    row[label] = Number.isFinite(v) ? v : ".";
  });

  return row;
})

downloadData = downloadButton(downloadWide, name)

downloadButton is a function that takes an array of data as the first argument and the name you want to call the file as the second argument. It displays the filename and the size of the file in kilobytes.

Plot Title

ageQuestion = significance.length > 1
  ? "Respondents Aged 19 through 65"
  : "Respondents Aged 19 through 30"

Rendered Plot for Combined Age Groups

import {addTooltips} from "@mkfreeman/plot-tooltip"

formatter = d3.timeFormat("%Y")

color = d3.scaleOrdinal(
  ["Age 19-30", "Age 35-50", "Age 55-65"],
  ["#59bbeb", "#6ac4a1", "#cca438"]
)

symbol = d3.scaleOrdinal(
  ["Age 19-30", "Age 35-50", "Age 55-65"],
  ["circle", "square", "triangle"]
)

dateFilter1 = radio.filter(d => d.year > new Date("1988-01-01"));
dateFilter2 = radio.filter(d => d.year > new Date("2008-01-01"));
dateFilter3 = radio.filter(d => d.year > new Date("2023-01-01"));
legendFilter1 = dateFilter1.filter(d => d.age === "Age 19-30");
legendFilter2 = dateFilter2.filter(d => d.age === "Age 35-50");
legendFilter3 = dateFilter3.filter(d => d.age === "Age 55-65");

legendData = [...legendFilter1, ...legendFilter2, ...legendFilter3].filter(d => Number.isFinite(d.estimate));
legendData
xDomain = {
  const [first, last] = d3.extent(legendData, d => d.year);
  if (first == null) return [new Date(1987, 0, 1), new Date(2024, 0, 1)];
  // d3.timeYear (local) matches d3.timeParse("%Y"), which parses in local time.
  return [d3.timeYear.offset(first, -1), last];
}

// Tick step in years: every year for short spans, thinning to round intervals
// (2/5/10/20) as the span grows, so labels never collide at width 900.
xTickStep = {
  const span = d3.timeYear.count(xDomain[0], xDomain[1]);
  const maxLabels = 14;
  return [1, 2, 5, 10, 20, 25, 50].find(s => span / s <= maxLabels) ?? 50;
}

plot = addTooltips(
  Plot.plot({
    ariaLabel: "Line graph depicting trends in drug use by age group (ages 19-30, ages 35-50, ages 55-65) over time",
    width: 900,
    height: 700,
    marginBottom: 50,
    marginLeft: 50,
    style: {
      overflow: "visible",
      fontSize: 12
    },
      symbol: {
      domain: new Set(legendData.map((d) => d.age)),
      range: [...new Set(legendData.map((d) => d.age))].map(symbol),
      legend: true,
      swatchSize: 23
    },
      color: {
      domain: new Set(legendData.map((d) => d.age)),
      range: [...new Set(legendData.map((d) => d.age))].map(color),
    },
    y: {
      label: "Percentage (%)",
      labelAnchor: "center",
      domain: yscale
    },
    x: {
      type: "time",
      domain: xDomain,
      interval: "year",
      ticks: d3.timeYear.every(xTickStep),
      tickFormat: "%Y",
      label: "Years",
      anchor: "bottom",
      labelAnchor: "center"
    },
    marks: [
      Plot.ruleY([0]),
      Plot.dot(legendData, {
        x: "year",
        y: "estimate",
        r: 4,
        fill: "age",
        symbol: "age",
        title: (d) => `${d.age} \n ${formatter(d.year)}: ${d.estimate == null ? "No data" : d.estimate.toFixed(1) + "%"}`
      }),
      Plot.line(legendData, {
        x: "year",
        y: "estimate",
        z: (d) => // This creates the line breaks
          [
            d.age,
            d.flag
          ].join(),
        stroke: "age"})
        ]
  }),
  { fill: "age" }
)

All Ages Heatmap

filter1 = radio.filter(d => d.age != "Ages 19-30")
filter2 = filter1.filter(d => d.age != "Ages 35-50" && Number.isFinite(d.estimate))

plot2 = addTooltips(Plot.plot({
  marginTop: 50,
  marginLeft: 100,  
  width: 1500,
  height: 500,
  grid: true,
  x: {axis: "top", label: "Year", type: "band"},
  y: {label: "Age", domain: ["Ages 19-20", "Ages 21-22", "Ages 23-24", "Ages 25-26", "Ages 27-28", "Ages 29-30", "Age 35", "Age 40", "Age 45", "Age 50", "Age 55", "Age 60"]},
  color: {type: "linear", scheme: "BuRd", legend: true, label: "Drug Use Estimate"},
  marks: [
    Plot.cell(filter2, {x: "year2", y: "age", fill: "estimate", inset: 0.5, title: (d) => `${d.age} \n ${formatter(d.year)}: ${d.estimate}%`}),
    Plot.text(filter2, {x: "year2", y: "age", text: "estimate", stroke: "white", strokeOpacity: 0.7, fill: "black", title: (d) => `${d.age} \n ${formatter(d.year)}: ${d.estimate}%`})
  ]
}),
{fill: "estimate"}
)

Combined Sig Plot with Legend

comboPlot = html`
<div style="position: relative;">
  ${plot}
  <div style="position: absolute; ${legendPlace} border: solid 1px black;">
    ${sigLegend}
  </div>
</div>`