sigData = FileAttachment("chapter5_sigtests.csv").csv({ typed: true })
sigsDrug = sigData.filter(
({ drug }) => drug === select
)
significance = sigsDrug.filter(({ time }) => time === radio[0].time)
significanceChapter 5
Adult and Young Adult Demographic Subgroups
Significance Tests
Significance Legend in Graph
parseNum = d => {
if (d == null || d === "") return null;
const n = +d;
return Number.isFinite(n) ? n : null;
}
fmtChange = d => (parseNum(d) == null ? "." : parseNum(d).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; 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.
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;
}
// The sigtests CSV labels groups lowercase (male/female/black/white/hisp);
// the graph CSV labels them uppercase (MEN/WOMEN/BLACK/WHITE/HISPANIC).
sigGroupMap = new Map([
["male", "MEN"],
["female", "WOMEN"],
["black", "BLACK"],
["white", "WHITE"],
["hisp", "HISPANIC"]
])
// Significance rows for the selected drug + time + age.
// NOTE: `significance` filters on drug and time only, so it holds all three age
// blocks. Filtering on age2 as well is what makes the legend track the age radio.
sigForAge = {
const age = radio1[0]?.age;
return significance.filter((d) => d.age2 === age);
}
// Keyed lookup replaces the old positional significance[0]..[4] indexing, which
// always landed on the five "fu" rows and so showed Ages 19-30 numbers
// regardless of the selected age.
sigByGroup = {
const m = new Map();
for (const d of sigForAge) {
const key = String(d.Group ?? d.group ?? "").trim().toLowerCase();
const g = sigGroupMap.get(key);
if (g) m.set(g, d);
}
return m;
}
// Caption year tracks the latest year present rather than a hardcoded one.
latestYear = d3.max(radio1, (d) => Number(d.year2))
legendCSS = `
caption {
font-size: 9px;
}
table {
margin-bottom: 0.5rem;
}
.small td {
font-size: 9px;
color: #000066;
}
`
makeLegend = (groups) => {
const style = document.createElement("style");
style.textContent = legendCSS;
const rowsFor = (label) => {
const s = sigByGroup.get(label);
return html`<tbody>
<tr>
<td>${label.charAt(0) + label.slice(1).toLowerCase()}</td>
<td>1-Year Change</td>
<td>${fmtChange(s?.oneyrchange)}</td>
<td>${sigMark(s, "OneYear1", "sig_oneyr")}</td>
</tr>
<tr>
<td> </td>
<td>5-Year Change</td>
<td>${fmtChange(s?.fiveyrchange)}</td>
<td>${sigMark(s, "FiveYear1", "sig_fiveyr")}</td>
</tr>
<tr style="background-color: white;">
<td></td>
<td>10-Year Change</td>
<td>${fmtChange(s?.tenyrchange)}</td>
<td>${sigMark(s, "TenYear1", "sig_tenyr")}</td>
</tr>
</tbody>`;
};
const table = html`<table class="small" style="width: 250px; ">
<caption style="color: #000066;"> <b><u>${latestYear} Trends</b></u> </caption>
${groups.map(rowsFor)}
</table>`;
const wrap = html`<div></div>`;
wrap.append(style, table);
return wrap;
}
sigLegend1 = makeLegend(["MEN", "WOMEN"])
sigLegend2 = makeLegend(["BLACK", "HISPANIC", "WHITE"])
legendPlace = {if (racedata[0]?.age == "Ages 35-50" || racedata[0]?.age == "Ages 55-65"){
return "top: 50px; left: 100px;";
} else if (racedata[0]?.drug == "Vaping Nicotine") {
return "top: 50px; left: 100px;";
} else if (racedata[0]?.drug == "Cigarettes") {
return "top: 60px; right: 10px;";
} else {
return "bottom: 65px; right: 50px;";
}
}Combined Sig Plot with Legend
combosexPlot = html`
<div style="position: relative;">
${sexplot}
<div style="position: absolute; ${legendPlace} border: solid 1px black;">
${sigLegend1}
</div>
</div>`comboracePlot = html`
<div style="position: relative;">
${raceplot}
<div style="position: absolute; ${legendPlace} border: solid 1px black;">
${sigLegend2}
</div>
</div>`rawdata = FileAttachment("final_graph5_data.csv").csv({ typed: true })
alphabetical = rawdata.sort((a, b) => {
if (a.drug && b.drug) {
return a.drug.localeCompare(b.drug);
} else {
return 0; // Preserve the order if 'name' is missing
}
});
parser = d3.timeParse("%Y")
format = d3.format(".4")
data = {
const subset = alphabetical.map(
({ year, drug, estimate, age, time, sex, raceEthnicity }) => ({
year: parser(year),
year2: format(year),
estimate: parseNum(estimate),
drug: drug,
age: age,
time: time,
sex: sex,
raceEthnicity: raceEthnicity
})
);
const filtered = subset.filter(thing =>
thing.drug?.toLocaleLowerCase() === select?.toLocaleLowerCase()
);
return filtered;
}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
import {PersistInput} from "@john-guerra/persist-input@latest"
viewof select = PersistInput("drug",
Inputs.select(
rawdata.map((d) => d.drug),
{
label: "Drug",
value: "Alcohol",
width: 175,
unique: true
}
))
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
});
}
viewof radio1 = {
const values = d3.group(radio, (d) => d.age);
return Inputs.radio(values, {
key: values.has("Ages 19-30")
? "Ages 19-30"
: values.keys().next().value
});
}import {addTooltips} from "@mkfreeman/plot-tooltip"
formatter = d3.timeFormat("%Y")
color1 = d3.scaleOrdinal(
["BLACK", "HISPANIC", "WHITE"],
["#59bbeb", "#6ac4a1", "#cca438"]
)
symbol1 = d3.scaleOrdinal(
["WHITE", "BLACK", "HISPANIC"],
["circle", "square", "triangle"]
)
raceplot = addTooltips(
Plot.plot({
ariaLabel: "Line graph depicting trends in drug use by Black, Hispanic, and White adults over time",
width: 900,
height: 700,
marginBottom: 50,
style: {
overflow: "visible",
fontSize: 12
},
symbol: {
domain: new Set(racedata.map((d) => d.raceEthnicity)),
range: [...new Set(racedata.map((d) => d.raceEthnicity))].map(symbol1),
legend: true,
swatchSize: 23
},
color: {
domain: new Set(racedata.map((d) => d.raceEthnicity)),
range: [...new Set(racedata.map((d) => d.raceEthnicity))].map(color1),
},
y: {
label: "Percentage (%)",
labelAnchor: "center"
},
x: {
type: "time",
label: "Years",
anchor: "bottom",
labelAnchor: "center",
domain: [new Date("1987-01-01"), new Date("2024-01-01")]
},
marks: [
Plot.ruleY([0]),
Plot.dot(racedata, {
x: "year",
y: "estimate",
r: 4,
fill: "raceEthnicity",
symbol: "raceEthnicity",
title: (d) => `${d.raceEthnicity} \n ${formatter(d.year)}: ${d.estimate == null ? "No data" : d.estimate.toFixed(1) + "%"}`
}),
Plot.line(racedata, {
x: "year",
y: "estimate",
z: (d) => // This creates the line breaks
[
d.raceEthnicity,
d.Flag
].join(),
stroke: "raceEthnicity"})
]
}),
{ fill: "raceEthnicity" }
)
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 and female adults over time",
width: 900,
height: 700,
marginBottom: 50,
style: {
overflow: "visible",
fontSize: 12
},
symbol: {
domain: new Set(sexdata.map((d) => d.sex)),
range: [...new Set(sexdata.map((d) => d.sex))].map(symbol2),
legend: true,
swatchSize: 23
},
color: {
domain: new Set(sexdata.map((d) => d.sex)),
range: [...new Set(sexdata.map((d) => d.sex))].map(color2),
},
y: {
label: "Percentage (%)",
labelAnchor: "center"
},
x: {
type: "time",
label: "Years",
anchor: "bottom",
labelAnchor: "center",
domain: [new Date("1987-01-01"), new Date("2024-01-01")]
},
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" }
)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)Horizontal Estimates and Significance Table
pivotYears = [...new Set(radio1.map(d => Number(d.year2)))]
.filter(Number.isFinite)
.sort((a, b) => a - b)
// One row per group; year columns are keyed "_1988" etc. so they stay valid
// object keys and sort in the order given by pivotColumns.
makePivotRows = (seriesRows, groupOf, groupOrder) =>
groupOrder
.map(g => {
const row = { group: g };
pivotYears.forEach(y => {
const match = seriesRows.find(d => groupOf(d) === g && Number(d.year2) === y);
row["_" + y] = match ? parseNum(match.estimate) : null;
});
const sig = sigByGroup.get(g);
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 = [
"group",
...pivotYears.map(y => "_" + y),
"oneyr_change",
"oneyr_sig",
"fiveyr_change",
"fiveyr_sig",
"tenyr_change",
"tenyr_sig"
]
pivotHeader = ({
group: "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 = ({
group: 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;
}
// Builds the dropdown + table pair for one tab. idSuffix keeps the accordion
// ids unique across the Sex and Race/Ethnicity tabs.
makePivotAccordion = (seriesRows, groupOf, groupOrder, idSuffix, label) => {
const table = Inputs.table(makePivotRows(seriesRows, groupOf, groupOrder), {
columns: pivotColumns,
header: pivotHeader,
format: pivotFormat,
rows: 10,
width: 840
});
const accId = "accordionFlush" + idSuffix;
const headId = "flush-heading" + idSuffix;
const bodyId = "flush-collapse" + idSuffix;
const el = html`
<div style="width: 950px; max-width: 100%;" class="accordion accordion-flush" id="${accId}">
<div class="accordion-item">
<h2 class="accordion-header" id="${headId}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="${"#" + bodyId}" aria-expanded="false" aria-controls="${bodyId}">
${radio[0].drug}: ${radio[0].time} Estimates and Significance by ${label}
</button>
</h2>
<div id="${bodyId}" class="accordion-collapse collapse" aria-labelledby="${headId}" data-bs-parent="${"#" + accId}">
<div class="accordion-body">
<div class="center"><div>${table}</div></div>
</div>
</div>
</div>`;
const panel = el.querySelector("#" + bodyId);
// 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(table)) {
// Layout may not be settled yet; try again on the next frame.
requestAnimationFrame(() => scrollTableRight(table));
}
};
// 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;
}
pivotSex = makePivotAccordion(sexdata, d => d.sex, ["MEN", "WOMEN"], "Sex", "Sex")
pivotRace = makePivotAccordion(racedata, d => d.raceEthnicity, ["BLACK", "HISPANIC", "WHITE"], "Race", "Race/Ethnicity")