In ObservableJS you can embed HTML, the html with the two escape characters allows you to write anything that you could put within an HTML. Here I use CSS in the beginning to style the table and to make it responsive to screen size changes. Then I list out all of the estimates by connecting them to the htmlTables dataset. When we search for new drugs via the search input or change the reporting interval via the radio buttons it updates the dataset. The estimates change dynamically because of the ${} which inject the newly updated estimates via javascript. I named the code cell wideTable to use later on.
Accordion Buttons
accordionButtons =html`<div style="width: 90%" 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"> Table with Detailed Estimates </button> </h2> <div id="flush-collapseOne" class="accordion-collapse collapse" aria-labelledby="flush-headingOne" data-bs-parent="#accordionFlushExample"> <div class="accordion-body"> <div class="branch"><div>${wideTable}</div></div> <div> <p style="font-size: small; margin-bottom: 0px;">Note: Level of significance: *=p<.05, **=p<.01, ***=p<.001. <br> "." indicates data is not available. <br> "‡" indicates that the question changed the following year. <br> "§" indicates insufficient data for that year. <br> <a href="https://monitoringthefuture.org/data/bx-by/drug-prevalence/Footnotes.pdf">Footnotes (PDF)</a></p></div> </div> </div> </div> <div class="accordion-item"> <h2 class="accordion-header" id="flush-headingTwo"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#flush-collapseTwo" aria-expanded="false" aria-controls="flush-collapseTwo"> Commentary </button> </h2> <div id="flush-collapseTwo" class="accordion-collapse collapse" aria-labelledby="flush-headingTwo" data-bs-parent="#accordionFlushExample"> <div style="display: flex; justify-content: center; width:100%" class="accordion-body">${publishdoc}</div> </div> </div></div>`
Here I am again using the html tag to embed some accordianButtons. Because Quarto uses Bootstrap for styling, I just need to add the class and id tags for the buttons to inherit the button styles. In addition, I use ${wideTable} to place the reactive html table code and ${editdoc} to place the linked google doc within the accordian buttons so that we will see it when we click on the button. I also link to a dataset that has all of the footnote letter superscripts for the prevalence figures ${googledoclink[0].Superscript} and updates according to the drug.
Citation
citation =html`<div style="max-width: 750px;"><p style="font-size: small;">Miech, R. A., Johnston, L. D., Patrick, M. E., & O’Malley, P. M. (2025). Monitoring the Future national survey results on drug use, 1975–2024: Overview and detailed results for secondary school students. Monitoring the Future Monograph Series. Ann Arbor, MI: Institute for Social Research, University of Michigan. Available at <a href="https://monitoringthefuture.org/results/annual-reports/">https://monitoringthefuture.org/results/annual-reports/</a></p></div>`
Here is the citation section that links to the monographs on the Monitoring the Future website.
This is where the commentary dataset lives. It is set up to filter by DrugName via the search input. This houses the links to both the editable and the published google docs organized by drug name. In addition it has each footnote letter organized by drug name. I named each version of google doc so that I can easily switch between them.
Table Data
tables =FileAttachment("tabledata.csv").csv({ typed:true })tables2 =FileAttachment("tabledata.csv").csv()tablesDrug = tables.filter( ({ drug }) => drug.toLocaleLowerCase() === select.toLocaleLowerCase())tablesDrug2 = tables2.filter( ({ drug }) => drug.toLocaleLowerCase() === select.toLocaleLowerCase())tablesDrugTime = tablesDrug.filter(({ time }) => time === radio[0].time)tablesDrugTime2 = tablesDrug2.filter(({ time }) => time === radio[0].time)tablesDrug2
tablesDrugTime
tablesDrugTime2
htmlTables = tablesDrugTime2htmlTables
This is the data in wide format for the HTML tables. It is filtered by both Drug name and by Time reporting interval. I had to do it twice because one needs to be read by the graph for the asterisks and one needs to be displayed in the HTML tables. For some reason (still not 100% sure why) in order for the negative numbers to show in the HTML tables it needs to be untyped.
parser = d3.timeParse("%Y")data = { // This creates new variablesconst subset = prevalence.map( ({ drug, time, grade, year, estimate, LBYear1, LBYear2 }) => ({drug: drug,time: time,grade: grade,year:parser(year),estimate:+estimate.toFixed(1),LBYear1:parser(LBYear1),LBYear2:parser(LBYear2) }) );return subset.filter( ({ drug }) => drug.toLocaleLowerCase() === select.toLocaleLowerCase() );}data
This is the data for the Plot. I attach the .csv file and use .csv({ typed: true }) to try to have ObservableJS guess the data types of each of the variables. I do some data management to set it up to be read by Observable Plot the data visualization library. I name a time parser function parser = d3.timeParse("%Y") that I use later on to change the year variable from a number to a date year: parser(year) in addition I created two more variables to denote when there needs to be a line break LBYear1 and LBYear1. I coerce the estimate variable into a number and round it to the tenths place estimate: +estimate.toFixed(1). Then after I rewrite my variables I set the data to filter based on the drug name by the search input.
Search Bar
import {PersistInput} from"@john-guerra/persist-input"import {aq, op} from"@uwdata/arquero"viewof drugs = aq // viewof shows the table view, but assigns the table value.fromCSV(awaitFileAttachment("plotdata.csv").text()).groupby("drug").count().view({ height:240 })drugSelection = drugs.objects()viewof search =PersistInput( // This creates a reactive input called search"drug", form2) form2 = {const form = Inputs.text({datalist: drugSelection.map((d) => d.drug),placeholder:"Search Drug",submit:true,autocomplete:false,spellcheck:true,width:300 }); form.addEventListener("input", () => {form.text.value=""});return form;}select = search ? search :"Alcohol"
Here I import a function from this notebook to create a persistent URL hash based on the drug name that allows users to copy the URL and share a specific drug. For example, searching for Adderall would create #drug="Adderall" at the end of the URL that allows you to share the dashboard with the relevant Adderall information. I also imported the Aquero Library which is a data management library for JavaScript similar to R’s Tidyverse package. I group the data by drug with .groupby("drug") and create a list of drug names to choose from with drugSelection = drugs.objects() and datalist: drugSelection.map((d) => d.drug). The viewof in front of search is unique to ObservableJS. It allows you to pair inputs with values. Here is more information on views.. Lastly, I create a if statement with a ternary operator. So that if the search bar is empty, the value for the drug name defaults to Alcohol. Otherwise if there is a value submitted in the search bar it will default to that value.
This is another input called radio it uses the plot data to extract the different time periods as options. This sets Last 12 Months as the default reporting interval. If there is no Last 12 Months value, then it selects the next value within that drug’s set of reporting intervals. The viewof allows me to use the filtered data by reporting interval which I will refer to later on.
Zoom-in Button
functionbuttonToggle({ onText ="Zoom in", offText ="Zoom out", value =0, click = (value, clicks) => clicks} = {}) {let text = onText, ml =html`<button type="button" class="btn btn-quarto" >${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(...radio.map((o) => o.estimate))yscale = {if (zoomInButton %2==0) {return [0,100];// If this statement is true, return this } else {return [0, max];// If the second statement is true, return this }}viewof zoomInButton =buttonToggle()
This is a function to create the Zoom in button. It basically updates the Y Axis of the graph from the default of 100 as the max to whatever the max is of the current drug and reporting interval. The buttonToggle creates a value (either even or odd) when clicked that changes the name of the button and is used within the yscale if statement that returns either the full scale of 100 or that drug’s max estimate. To get the max estimate max = Math.max(...radio.map((o) => o.estimate)) It takes the radio dataset that is filtered by both drug name and time reporting interval and finds the max value via the Math.max() function bult-in to JavaScript. Then I use the viewof operator to be able to name the input and import it.
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
title =html`<h3> ${radio[0].drug.toLocaleUpperCase()}: Trends in <u>${radio[0].time}</u> Prevalence of Use in ${gradeQuestion} </h3>`gradeQuestion = gradeTitle.length>1?"8th, 10th, and 12th Grade":"12th Grade"radioSet =newSet(radio.map((d) => d.grade))gradeTitle = [...radioSet]
This creates the title of the plot by reactively updating via the radio data set. First is the drug name ${radio[0].drug} then the reporting interval ${radio[0].time} then whether it is all grades or just 12th grade ${gradeQuestion}. That variable is just a ternary operator that figures out if there is more than one grade variable.
plot =addTooltips( Plot.plot({ariaLabel:"Drug Prevalence Line Charts",ariaDescription:"Drug Prevalence Chart showing estimates of use over time (1975 to 2024) in 8th, 10th, and 12th grade filtered by drug name and reporting interval. Currently showing ${radio[0].drug}",width:900,height:570,marginBottom:50,style: {overflow:"visible",fontSize:12 },symbol: {domain:newSet(radio.map((d) => d.grade)),range: [...newSet(radio.map((d) => d.grade))].map(symbol),legend:true,swatchSize:23 },color: {domain:newSet(radio.map((d) => d.grade)),range: [...newSet(radio.map((d) => d.grade))].map(color) },y: {label:"Percentage (%)",labelAnchor:"center",domain: yscale },x: {type:"time",domain: [newDate("1974-01-01"),newDate("2024-01-01")],label:"Years",labelAnchor:"center" },marks: [ Plot.ruleY([0]), Plot.dot(radio, {r:5,x:"year",y:"estimate",symbol:"grade",fill:"grade",title: (d) =>`${d.grade}\n${formatter(d.year)}: ${d.estimate}%` }), Plot.line(radio, {x:"year",y:"estimate",z: (d) =>// This creates the line breaks [ d.grade, d.LBYear1===null? d.year.getUTCFullYear() >1974: d.year.getUTCFullYear() > d.LBYear1.getUTCFullYear(), d.LBYear2===null? d.year.getUTCFullYear() >1974: d.year.getUTCFullYear() > d.LBYear2.getUTCFullYear() ].join(),stroke:"grade" }), Plot.text( radio.filter((d) => d.grade==="8th Grade"), Plot.selectLast({x:"year",y:"estimate",text: (d) =>`${tablesDrugTime[0].plotSig===null?" ": tablesDrugTime[0].plotSig}`,// This is the label that displays in the tooltiptextAnchor:"start",fill:"#ff57f9",fontSize:20,fontWeight:800,dx:10 }) ), Plot.text( radio.filter((d) => d.grade==="10th Grade"), Plot.selectLast({x:"year",y:"estimate",text: (d) =>`${tablesDrugTime[1].plotSig===null?" ": tablesDrugTime[1].plotSig}`,textAnchor:"start",fill:"#ff57f9",fontSize:20,fontWeight:800,dx:10 }) ), Plot.text( radio.filter((d) => d.grade==="12th Grade"), Plot.selectLast({x:"year",y:"estimate",text: (d) =>`${tablesDrugTime[2]?.plotSig==null?" ": tablesDrugTime[2].plotSig}`,textAnchor:"start",fill:"#ff57f9",fontSize:20,fontWeight:800,dx:10 }) ) ] }), { fill:"grade" })
Here I import a tooltip via this notebook Observable has created new tooltips since I wrote this code. So I may go back and change it but for now I am still using the imported addTooltip() function. I create the color and symbol scales for the grades and name a function to format the year. Then I create three functions that check if the results of the significance test within the htmlTable dataset have astersisk. If they do then I display them in the graph. If they don’t nothing is displayed. For more on how the graph is made check out Observable Plot.
Diverging Bar Chart
sigdata =FileAttachment("sigbarchart.csv").csv({typed:true}).then(data => data.map((d) => ({...d,value: (d[2023] - d[2024]) / d[2024]})))sigDrug = sigdata.filter( ({ drug }) => drug.toLocaleLowerCase() === select.toLocaleLowerCase())sigDrugTime = sigDrug.filter(({ time }) => time === radio[0].time)sigDrugTime