parseNum = d => {
if (d == null || d === "") return null;
const n = +d;
return Number.isFinite(n) ? n : null;
}
// Estimates and changes are reported to the nearest tenth.
round1 = d => {
const n = parseNum(d);
return n == null ? null : Math.round(n * 10) / 10;
}
// Empty change -> "."; empty sig -> "N/A"; keeps "n.s." and "p<.xx" verbatim.
fmtChange = d => {
const n = parseNum(d);
return n == null ? "." : n.toFixed(1);
}
fmtSig = d => (d == null || d === "") ? "N/A" : d
// Baseline-aware significance marker. A trend window only exists if its
// baseline year is present; when the baseline (OneYear1 / FiveYear1 /
// TenYear1) is missing, show "N/A" regardless of what sig_* holds.
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;
}
// Treats both the "." sentinel and blank/null as "not applicable to this row".
present = v => v != null && String(v).trim() !== "" && String(v).trim() !== "."
// First year reported in the dashboard line graphs.
minYear = 1988Chapter 4
College and Noncollege
Shared Helpers
Significance Tests
sigData = FileAttachment("chapter4_sigtests.csv").csv({ typed: true })
sigsDrug = sigData.filter(
({ drug }) => drug === select
)
significance = sigsDrug.filter(({ time }) => time === radio[0].time)
significancecollegeSig = {
const order = ["College Student", "Non-College Adult"];
return significance
.filter(d => present(d.college))
.sort((a, b) => order.indexOf(a.college) - order.indexOf(b.college));
}
sexSig = {
const order = ["College Student Men", "College Student Women"];
return significance
.filter(d => present(d.sex))
.sort((a, b) => order.indexOf(a.sex) - order.indexOf(b.sex));
}Significance Legend in Graph
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>${trendYear} Trends</b></u> </caption>
<tr>
<td>College Students</td>
<td>1-Year Change</td>
<td>${fmtChange(collegeSig[0]?.oneyrchange)}</td>
<td>${sigMark(collegeSig[0], "OneYear1", "sig_oneyr")}</td>
</tr>
<tr>
<td> </td>
<td>5-Year Change</td>
<td>${fmtChange(collegeSig[0]?.fiveyrchange)}</td>
<td>${sigMark(collegeSig[0], "FiveYear1", "sig_fiveyr")}</td>
</tr>
<tr style="background-color: white;">
<td></td>
<td>10-Year Change</td>
<td>${fmtChange(collegeSig[0]?.tenyrchange)}</td>
<td>${sigMark(collegeSig[0], "TenYear1", "sig_tenyr")}</td>
</tr>
<tr>
<td>Noncollege Young Adults</td>
<td>1-Year Change</td>
<td>${fmtChange(collegeSig[1]?.oneyrchange)}</td>
<td>${sigMark(collegeSig[1], "OneYear1", "sig_oneyr")}</td>
</tr>
<tr>
<td> </td>
<td>5-Year Change</td>
<td>${fmtChange(collegeSig[1]?.fiveyrchange)}</td>
<td>${sigMark(collegeSig[1], "FiveYear1", "sig_fiveyr")}</td>
</tr>
<tr style="background-color: white;">
<td></td>
<td>10-Year Change</td>
<td>${fmtChange(collegeSig[1]?.tenyrchange)}</td>
<td>${sigMark(collegeSig[1], "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>${trendYear} Trends</b></u> </caption>
<tr>
<td>College Men</td>
<td>1-Year Change</td>
<td>${fmtChange(sexSig[0]?.oneyrchange)}</td>
<td>${sigMark(sexSig[0], "OneYear1", "sig_oneyr")}</td>
</tr>
<tr>
<td> </td>
<td>5-Year Change</td>
<td>${fmtChange(sexSig[0]?.fiveyrchange)}</td>
<td>${sigMark(sexSig[0], "FiveYear1", "sig_fiveyr")}</td>
</tr>
<tr style="background-color: white;">
<td></td>
<td>10-Year Change</td>
<td>${fmtChange(sexSig[0]?.tenyrchange)}</td>
<td>${sigMark(sexSig[0], "TenYear1", "sig_tenyr")}</td>
</tr>
<tr>
<td>College Women</td>
<td>1-Year Change</td>
<td>${fmtChange(sexSig[1]?.oneyrchange)}</td>
<td>${sigMark(sexSig[1], "OneYear1", "sig_oneyr")}</td>
</tr>
<tr>
<td></td>
<td>5-Year Change</td>
<td>${fmtChange(sexSig[1]?.fiveyrchange)}</td>
<td>${sigMark(sexSig[1], "FiveYear1", "sig_fiveyr")}</td>
</tr>
<tr>
<td></td>
<td>10-Year Change</td>
<td>${fmtChange(sexSig[1]?.tenyrchange)}</td>
<td>${sigMark(sexSig[1], "TenYear1", "sig_tenyr")}</td>
</tr>
</table>`
// Caption year tracks the data instead of being hardcoded.
trendYear = d3.max(radio, d => d.year.getFullYear()) ?? ""
legendPlace = {if (collegedata[0]?.drug == "Cigarettes" && collegedata[0]?.time == "30 Day"){
return "top: 60px; right: 10px;";
} else if (collegedata[0]?.drug == "Vaping Nicotine" || collegedata[0]?.drug == "Vaping Cannabis"){
return "top: 50px; left: 100px;";
} else {
return "bottom: 65px; right: 50px;";
}
}Combined Sig Plot with Legend
Plot Data
rawdata = FileAttachment("final_graph4_data.csv").csv({ typed: true })
parser = d3.timeParse("%Y")
format = d3.format(".4")
// Parsed and sorted ONCE, on load. This cell doesn't reference `select`, so it
// is not recomputed when the drug dropdown changes -- only the cheap filter in
// `data` below re-runs. Estimates before minYear are dropped here so every
// downstream consumer (plots, tooltips, tables, download) sees the same span.
parsedAll = {
const rows = [];
for (const { year, drug, estimate, time, sex, college } of rawdata) {
const y = parser(year);
if (!(y instanceof Date) || isNaN(y) || y.getFullYear() < minYear) continue;
const est = round1(estimate);
if (est == null) continue;
rows.push({
year: y,
year2: format(year),
estimate: est,
drug,
time,
sex,
college
});
}
// Sort by drug, then by year, so Plot.line connects points in time order.
rows.sort((a, b) =>
(a.drug ?? "").localeCompare(b.drug ?? "") || a.year - b.year
);
return rows;
}
// Distinct drugs for the dropdown, computed once rather than mapping the full
// dataset (and de-duplicating) on every render.
drugOptions = [...new Set(parsedAll.map(d => d.drug))].sort((a, b) => a.localeCompare(b))
data = {
const key = select?.toLocaleLowerCase();
return parsedAll.filter(d => d.drug?.toLocaleLowerCase() === key);
}Dropdown Input
PersistInput = (field, input) => {
const getHashValue = () => {
let hashValue = new URLSearchParams(location.hash.slice(1)).get(field);
try {
hashValue = JSON.parse(hashValue);
} catch {}
return hashValue ? hashValue : input.value;
};
const setHashValue = (val) => {
const params = new URLSearchParams(location.hash.slice(1));
params.set(field, JSON.stringify(val));
html`<a href="#${params.toString()}">`.click();
};
const setInput = (val) => {
input.value = val;
input.dispatchEvent(new Event("input", { bubbles: true }));
};
const onInputChange = () => {
setHashValue(input.value);
};
input.addEventListener("input", onInputChange);
setInput(getHashValue() || input.value);
onInputChange();
return input;
}
viewof select = PersistInput("drug",
Inputs.select(
drugOptions,
{
value: "Alcohol",
width: 175,
unique: true
}
))Radio Buttons
Citations
citation1 = 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>`citation2 = 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>`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}`
data2 = radio.map(d => ({...d, year: d.year2}))
data3 = data2.map(({year2, ...keep}) => keep)
downloadData1 = downloadButton(data3, name)
downloadData2 = downloadButton(data3, name)Estimates and Significance Tables
trendCols = ["1-Yr Change", "1-Yr Sig.", "5-Yr Change", "5-Yr Sig.", "10-Yr Change", "10-Yr Sig."]
// One row per comparison group; columns are the per-year estimates followed by
// the trend / p-value labels.
makeRows = (groups, estData, sigRows, field, years) =>
groups.map(({ key, label }) => {
const row = { Group: label };
// Index once per group: avoids a linear .find() per year cell.
const byYear = new Map(
estData.filter(d => d[field] === key).map(d => [String(d.year.getFullYear()), d.estimate])
);
years.forEach(y => {
row[y] = byYear.has(y) ? byYear.get(y) : null;
});
const sig = sigRows.find(r => r[field] === key) ?? null;
row["1-Yr Change"] = sig?.oneyrchange;
row["1-Yr Sig."] = sigMark(sig, "OneYear1", "sig_oneyr");
row["5-Yr Change"] = sig?.fiveyrchange;
row["5-Yr Sig."] = sigMark(sig, "FiveYear1", "sig_fiveyr");
row["10-Yr Change"] = sig?.tenyrchange;
row["10-Yr Sig."] = sigMark(sig, "TenYear1", "sig_tenyr");
return row;
})
makeFormat = years => ({
Group: d => html`<b>${d}</b>`,
"1-Yr Change": fmtChange,
"1-Yr Sig.": fmtSig,
"5-Yr Change": fmtChange,
"5-Yr Sig.": fmtSig,
"10-Yr Change": fmtChange,
"10-Yr Sig.": fmtSig,
...Object.fromEntries(
years.map(y => [y, d => (d == null || isNaN(d)) ? "." : d.toFixed(1)])
)
})
yearsOf = arr => [...new Set(arr.map(d => String(d.year.getFullYear())))].sort()
// 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;
}
collegeYears = yearsOf(collegedata)
sexYears = yearsOf(sexdata)
collegeRows = makeRows(
[
{ key: "College Student", label: "College Students" },
{ key: "Non-College Adult", label: "Noncollege Young Adults" }
],
collegedata, collegeSig, "college", collegeYears
)
sexRows = makeRows(
[
{ key: "College Student Men", label: "College Men" },
{ key: "College Student Women", label: "College Women" }
],
sexdata, sexSig, "sex", sexYears
)
collegeCombinedTable = Inputs.table(collegeRows, {
columns: ["Group", ...collegeYears, ...trendCols],
format: makeFormat(collegeYears),
rows: 5,
width: 840
})
sexCombinedTable = Inputs.table(sexRows, {
columns: ["Group", ...sexYears, ...trendCols],
format: makeFormat(sexYears),
rows: 5,
width: 840
})Table Accordions
makeTableAccordion = (id, label, table) => {
const el = html`
<div style="width: 950px; max-width: 100%; box-sizing: border-box;" class="accordion accordion-flush" id="accordion-${id}">
<div class="accordion-item">
<h2 class="accordion-header" id="heading-${id}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${id}" aria-expanded="false" aria-controls="collapse-${id}">
${label}
</button>
</h2>
<div id="collapse-${id}" class="accordion-collapse collapse" aria-labelledby="heading-${id}" data-bs-parent="#accordion-${id}">
<div class="accordion-body" style="max-width: 100%; overflow-x: auto; box-sizing: border-box;">
<div class="center" style="max-width: 100%; overflow-x: auto;"><div>${table}</div></div>
</div>
</div>
</div>
</div>`;
const panel = el.querySelector(`#collapse-${id}`);
const pinRight = () => {
if (!scrollTableRight(table)) {
requestAnimationFrame(() => scrollTableRight(table));
}
};
panel.addEventListener("show.bs.collapse", () => requestAnimationFrame(pinRight));
panel.addEventListener("shown.bs.collapse", pinRight);
new MutationObserver(() => {
if (panel.classList.contains("show")) requestAnimationFrame(pinRight);
}).observe(panel, { attributes: true, attributeFilter: ["class"] });
return el;
}
collegeTablesAccordion = makeTableAccordion(
"college",
`${radio[0].drug}: ${radio[0].time} Estimates and Significance by College Status`,
collegeCombinedTable
)
sexTablesAccordion = makeTableAccordion(
"sex",
`${radio[0].drug}: ${radio[0].time} Estimates and Significance by Sex`,
sexCombinedTable
)Rendered Plots
import {addTooltips} from "@mkfreeman/plot-tooltip"
formatter = d3.timeFormat("%Y")
// X-axis domain derived from the years actually present for the selected
// drug / time period. Padded one year to the left so the first point isn't on
// the axis edge; the right edge sits on the last year of data.
xDomainFor = arr => {
const [first, last] = d3.extent(arr, d => d.year);
if (first == null) return [new Date(1987, 0, 1), new Date(2025, 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.
xTickStepFor = arr => {
const dom = xDomainFor(arr);
const span = d3.timeYear.count(dom[0], dom[1]);
const maxLabels = 14;
return [1, 2, 5, 10, 20, 25, 50].find(s => span / s <= maxLabels) ?? 50;
}
color1 = d3.scaleOrdinal(
["College Students", "Noncollege Young Adults", "WHITE"],
["#59bbeb", "#6ac4a1", "#cca438"]
)
symbol1 = d3.scaleOrdinal(
["College Students", "Noncollege Young Adults"],
["circle", "square"]
)
// Scale domains computed once per selection instead of rebuilding the same
// Set three times inside the plot spec.
collegeGroups = [...new Set(collegedata.map(d => d.college))]
sexGroups = [...new Set(sexdata.map(d => d.sex))]
collegeplot = addTooltips(
Plot.plot({
ariaLabel: "Line graph depicting trends in drug use by college students and noncollege young adults over time",
width: 900,
height: 700,
marginBottom: 50,
style: {
overflow: "visible",
fontSize: 12
},
symbol: {
domain: collegeGroups,
range: collegeGroups.map(symbol1),
legend: true,
swatchSize: 23
},
color: {
domain: collegeGroups,
range: collegeGroups.map(color1),
},
y: {
label: "Percentage (%)",
labelAnchor: "center"
},
x: {
type: "time",
label: "Years",
anchor: "bottom",
labelAnchor: "center",
domain: xDomainFor(collegedata),
interval: "year",
ticks: d3.timeYear.every(xTickStepFor(collegedata)),
tickFormat: "%Y"
},
marks: [
Plot.ruleY([0]),
Plot.dot(collegedata, {
x: "year",
y: "estimate",
r: 4,
fill: "college",
symbol: "college",
title: (d) => `${d.college} \n ${formatter(d.year)}: ${d.estimate == null ? "No data" : d.estimate.toFixed(1) + "%"}`
}),
Plot.line(collegedata, {
x: "year",
y: "estimate",
z: (d) => // This creates the line breaks
[
d.college,
d.Flag
].join(),
stroke: "college"})
]
}),
{ fill: "college" }
)
color2 = d3.scaleOrdinal(
["MEN", "WOMEN"],
["#59bbeb", "#6ac4a1"]
)
symbol2 = d3.scaleOrdinal(
["MEN", "WOMEN"],
["circle", "square"]
)
sexplot = addTooltips(
Plot.plot({
ariaLabel: "Line graph depicting trends in drug use by male college students and female college students over time",
width: 900,
height: 700,
marginBottom: 50,
style: {
overflow: "visible",
fontSize: 12
},
symbol: {
domain: sexGroups,
range: sexGroups.map(symbol2),
legend: true,
swatchSize: 23
},
color: {
domain: sexGroups,
range: sexGroups.map(color2),
},
y: {
label: "Percentage (%)",
labelAnchor: "center"
},
x: {
type: "time",
label: "Years",
anchor: "bottom",
labelAnchor: "center",
domain: xDomainFor(sexdata),
interval: "year",
ticks: d3.timeYear.every(xTickStepFor(sexdata)),
tickFormat: "%Y"
},
marks: [
Plot.ruleY([0]),
Plot.dot(sexdata, {
x: "year",
y: "estimate",
r: 4,
fill: "sex",
symbol: "sex",
title: (d) => `${d.sex} \n ${formatter(d.year)}: ${d.estimate == null ? "No data" : d.estimate.toFixed(1) + "%"}`
}),
Plot.line(sexdata, {
x: "year",
y: "estimate",
z: (d) => // This creates the line breaks
[
d.sex,
d.Flag
].join(),
stroke: "sex"})
]
}),
{ fill: "sex" }
)