David Hall
  • Home
  • About
  • Blog
  • Teaching
  • Data
    • Data Home
    • MOUD Dashboard
    • Treatment Facility Data
    • Treatment Facility Dashboard
  • CV

Historical Treatment Facility Dashboard

Preliminary historical geography and characteristics reconstructed from SAMHSA treatment facility directories.
d3 = await import("https://cdn.jsdelivr.net/npm/d3@7/+esm")
Plot = await import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm")
topojson = await import("https://cdn.jsdelivr.net/npm/topojson-client@3/+esm")

status = await FileAttachment("data/status.json").json()
metadata = await FileAttachment("data/dashboard_metadata.json").json()
annual = await FileAttachment("data/annual_counts.csv").csv({typed: true})
stateCounts = await FileAttachment("data/state_counts.csv").csv({typed: true})
regionCounts = await FileAttachment("data/region_counts.csv").csv({typed: true})
population = await FileAttachment("data/state_population.csv").csv({typed: true})
availability = await FileAttachment("data/attribute_availability.csv").csv({typed: true})
filterCube = await FileAttachment("data/facility_filter_cube.csv").csv({typed: true})
crosswalk = await FileAttachment("data/harmonization_crosswalk.csv").csv({typed: true})

usTopo = await d3.json("https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json")
stateFeatures = topojson.feature(usTopo, usTopo.objects.states).features
fmtInt = (value) => new Intl.NumberFormat("en-US", {maximumFractionDigits: 0}).format(value ?? 0)
fmtRate = (value) => Number.isFinite(value)
  ? new Intl.NumberFormat("en-US", {maximumFractionDigits: 1}).format(value)
  : "Not available"
fmtChange = (value, suffix = "") => Number.isFinite(value)
  ? `${value > 0 ? "+" : ""}${new Intl.NumberFormat("en-US", {maximumFractionDigits: 1}).format(value)}${suffix}`
  : "Not available"
normalizeMulti = (value) => value == null ? [] : Array.isArray(value) ? value : [value]
splitValues = (value) => new Set(String(value || "").split("|").filter(Boolean))
surveyYears = metadata.years.map((d) => d.survey_year).sort((a, b) => a - b)
yearMetadata = new Map(metadata.years.map((d) => [d.survey_year, d]))
yearLabels = new Map(metadata.years.map((d) => [
  d.survey_year,
  `${d.survey_year} survey (${d.directory_year} directory)`
]))
populationByYearState = new Map(population.map((d) => [`${d.survey_year}|${d.state}`, d]))
availabilityByKey = new Map(availability.map((d) => [
  `${d.survey_year}|${d.characteristic}|${d.value}`,
  d.asked === true || String(d.asked).toLowerCase() === "true"
]))
stateNameByFips = new Map(stateFeatures.map((d) => [
  String(d.id).padStart(2, "0"),
  d.properties.name
]))
stateAbbrByFips = new Map(Object.entries(metadata.state_fips).map(([abbr, fips]) => [fips, abbr]))
stateOptions = Object.keys(metadata.state_fips).sort()
regionOptions = ["Northeast", "Midwest", "South", "West"]
groupOptions = (group) => metadata.filter_groups[group].map((d) => d.value)
groupLabel = (group, value) => metadata.filter_groups[group].find((d) => d.value === value)?.label ?? value
groupBit = (group, value) => metadata.filter_groups[group].find((d) => d.value === value)?.bit ?? 0
viewof selectedSurveyYear = Inputs.select(surveyYears, {
  label: "Survey year",
  value: d3.max(surveyYears),
  format: (d) => yearLabels.get(d)
})
viewof scaleMode = Inputs.radio(["Facility count", "Facilities per 100,000"], {
  label: "Map and trend scale",
  value: "Facility count"
})
viewof selectedOwnership = Inputs.select(groupOptions("ownership"), {
  label: "Ownership",
  multiple: true,
  size: 5,
  format: (d) => groupLabel("ownership", d)
})
viewof selectedCenterTypes = Inputs.select(groupOptions("center_type"), {
  label: "Center type",
  multiple: true,
  size: 5,
  format: (d) => groupLabel("center_type", d)
})
viewof selectedSettings = Inputs.select(groupOptions("setting"), {
  label: "Care setting",
  multiple: true,
  size: 4,
  format: (d) => groupLabel("setting", d)
})
viewof selectedPayments = Inputs.select(groupOptions("payment"), {
  label: "Payment accepted/assistance",
  multiple: true,
  size: 5,
  format: (d) => groupLabel("payment", d)
})
viewof selectedServiceFamilies = Inputs.select(groupOptions("service_family"), {
  label: "Service families",
  multiple: true,
  size: 7,
  format: (d) => groupLabel("service_family", d)
})
viewof serviceMatchMode = Inputs.radio(["All selected", "Any selected"], {
  label: "Multiple service selections",
  value: "All selected"
})
rawCodeOptions = availability
  .filter((d) => d.survey_year === selectedSurveyYear && d.characteristic === "original_code" && d.asked)
  .sort((a, b) => String(a.value).localeCompare(String(b.value)))
rawCodeLabels = new Map(rawCodeOptions.map((d) => [d.value, `${d.value}: ${d.label}`]))
viewof selectedRawCodes = Inputs.select(rawCodeOptions.map((d) => d.value), {
  label: "Advanced: original SAMHSA codes",
  multiple: true,
  size: 7,
  format: (d) => rawCodeLabels.get(d) ?? d
})

viewof comparisonStartYear = Inputs.select(surveyYears, {
  label: "Start year",
  value: d3.min(surveyYears),
  format: (d) => yearLabels.get(d)
})
viewof comparisonEndYear = Inputs.select(surveyYears, {
  label: "End year",
  value: d3.max(surveyYears),
  format: (d) => yearLabels.get(d)
})
overlayOptions = [
  "US",
  ...regionOptions.map((d) => `Region:${d}`),
  ...stateOptions.map((d) => `State:${d}`)
]
overlayLabel = (value) => {
  if (value === "US") return "United States"
  const [kind, name] = value.split(":")
  return kind === "Region" ? `${name} region` : (stateNameByFips.get(metadata.state_fips[name]) ?? name)
}
viewof selectedOverlays = Inputs.select(overlayOptions, {
  label: "Trend geographies (maximum five)",
  value: ["US"],
  multiple: true,
  size: 7,
  format: overlayLabel
})
viewof changeMetric = Inputs.radio(
  ["Absolute count change", "Percent change", "Change per 100,000"],
  {label: "State change-map metric", value: "Absolute count change"}
)
ownershipSelection = normalizeMulti(selectedOwnership)
centerSelection = normalizeMulti(selectedCenterTypes)
settingSelection = normalizeMulti(selectedSettings)
paymentSelection = normalizeMulti(selectedPayments)
serviceSelection = normalizeMulti(selectedServiceFamilies)
rawCodeSelection = normalizeMulti(selectedRawCodes)
overlaySelection = normalizeMulti(selectedOverlays).slice(0, 5)
perCapita = scaleMode === "Facilities per 100,000"

selections = ({
  ownership: ownershipSelection,
  center_type: centerSelection,
  setting: settingSelection,
  payment: paymentSelection,
  service_family: serviceSelection,
  original_code: rawCodeSelection
})

selectionAvailable = (year) => Object.entries(selections).every(([group, values]) =>
  values.every((value) => availabilityByKey.get(`${year}|${group}|${value}`) === true)
)

selectedMask = (group, values) => values.reduce((mask, value) => mask | groupBit(group, value), 0)
masks = ({
  ownership: selectedMask("ownership", ownershipSelection),
  center_type: selectedMask("center_type", centerSelection),
  setting: selectedMask("setting", settingSelection),
  payment: selectedMask("payment", paymentSelection),
  service_family: selectedMask("service_family", serviceSelection)
})

maskPasses = (row, group, values, matchAll = false) => {
  if (!values.length) return true
  const rowMask = +row[`${group}_mask`] || 0
  const wanted = masks[group]
  return matchAll ? (rowMask & wanted) === wanted : (rowMask & wanted) !== 0
}

rowPassesHarmonized = (row) => (
  maskPasses(row, "ownership", ownershipSelection) &&
  maskPasses(row, "center_type", centerSelection) &&
  maskPasses(row, "setting", settingSelection) &&
  maskPasses(row, "payment", paymentSelection) &&
  maskPasses(row, "service_family", serviceSelection, serviceMatchMode === "All selected")
)

rowPassesRawCodes = (row) => {
  if (!rawCodeSelection.length) return true
  const codes = splitValues(row.service_codes)
  return serviceMatchMode === "All selected"
    ? rawCodeSelection.every((code) => codes.has(code))
    : rawCodeSelection.some((code) => codes.has(code))
}
rowPassesShard = (row) => rowPassesHarmonized(row) && rowPassesRawCodes(row)

activeFilterParts = Object.entries(selections)
  .filter(([, values]) => values.length)
  .map(([group, values]) => {
    const labels = group === "original_code"
      ? values
      : values.map((value) => groupLabel(group, value))
    return `${group.replaceAll("_", " ")}: ${labels.join(", ")}`
  })
activeFilterSummary = activeFilterParts.length
  ? activeFilterParts.join("; ")
  : "All facilities"
Preliminary research visualization

All 22 parsed directories are available, but these are not validated release data. Counts and classifications can change as each directory completes manual gold-sample review. The selected year’s QA status appears throughout the dashboard.

Important

These historical records are not a current treatment locator. SAMHSA warns that pre-2021 N-SSATS and post-2020 N-SUMHSS survey data are not directly trend-comparable.

  • Overview
  • State Explorer
  • Time Comparison
  • County Map
  • Methods / Downloads
selectedAnnual = annual.find((d) => d.survey_year === selectedSurveyYear)
selectedYearMeta = yearMetadata.get(selectedSurveyYear)
html`<div class="dashboard-grid treatment-summary-grid">
  <div class="dashboard-card compact">
    <h3>Directories parsed</h3>
    <p class="dashboard-number"><strong>${status.parsed_years}</strong></p>
    <p class="muted">1998, 2000, 2001, and 2003–2021</p>
  </div>
  <div class="dashboard-card compact">
    <h3>Gold review progress</h3>
    <p class="dashboard-number"><strong>${status.reviewed_listings}</strong></p>
    <p class="muted">Listings reviewed across all years</p>
  </div>
  <div class="dashboard-card compact">
    <h3>Selected U.S. count</h3>
    <p class="dashboard-number"><strong>${fmtInt(selectedAnnual?.facility_count)}</strong></p>
    <p class="muted">50 states and DC; listing-year count</p>
  </div>
  <div class="dashboard-card compact">
    <h3>Selected-year QA</h3>
    <p class="dashboard-status"><strong>${selectedAnnual?.qa_status ?? "Not reviewed"}</strong></p>
    <p class="muted">${fmtInt(selectedAnnual?.reviewed_listings)} of ${fmtInt(selectedAnnual?.gold_target)} baseline reviews</p>
  </div>
</div>`
viewof selectedSurveyYear
Plot.plot({
  width: 960,
  height: 390,
  marginLeft: 70,
  x: {label: "Survey year", tickFormat: d3.format("d")},
  y: {label: "Directory listings", grid: true},
  marks: [
    Plot.ruleY([0], {stroke: "#cbd5e1"}),
    Plot.line(annual, {
      x: "survey_year",
      y: "facility_count",
      stroke: "#1f6f8b",
      strokeWidth: 2.5
    }),
    Plot.dot(annual, {
      x: "survey_year",
      y: "facility_count",
      fill: (d) => d.reviewed_listings > 0 ? "#d97706" : "#1f6f8b",
      r: 4,
      title: (d) => `${yearLabels.get(d.survey_year)}\nListings: ${fmtInt(d.facility_count)}\nQA: ${d.qa_status}`
    }),
    Plot.ruleX([2020], {stroke: "#b91c1c", strokeDasharray: "5,4"})
  ]
})

The analytical year is the survey year. The publication year remains in the selector because, for example, the 2000 directory reports the 1999 survey. The red rule marks the N-SSATS/N-SUMHSS transition. Counts are directory listings, not stable cross-year facility entities.

Explore facilities by state

viewof selectedSurveyYear
viewof scaleMode
viewof selectedOwnership
viewof selectedCenterTypes
viewof selectedSettings
viewof selectedPayments
viewof selectedServiceFamilies
viewof serviceMatchMode
viewof selectedRawCodes

Leave a filter empty to include all values. Selections within ownership, center type, setting, and payment use OR logic; filter groups combine with AND logic.

mapYearRows = await d3.csv(`data/facility_dashboard_${selectedSurveyYear}.csv`, d3.autoType)
mapIsAvailable = selectionAvailable(selectedSurveyYear)
mapFilteredRows = mapIsAvailable ? mapYearRows.filter(rowPassesShard) : []
mapCounts = d3.rollup(mapFilteredRows, (rows) => rows.length, (d) => d.state)
mapPopulationRows = population.filter((d) => d.survey_year === selectedSurveyYear)
mapStateRows = mapPopulationRows.map((pop) => {
  const count = mapCounts.get(pop.state) ?? 0
  const rate = pop.population > 0 ? count / pop.population * 100000 : NaN
  return {...pop, count, rate, value: perCapita ? rate : count}
})
mapByFips = new Map(mapStateRows.map((d) => [String(d.state_fips).padStart(2, "0"), d]))
mapTotal = d3.sum(mapStateRows, (d) => d.count)
mapPopulationTotal = d3.sum(mapStateRows, (d) => d.population)
mapRateTotal = mapPopulationTotal > 0 ? mapTotal / mapPopulationTotal * 100000 : NaN
mapIsAvailable
  ? html`<div class="dashboard-card filter-summary">
      <strong>${yearLabels.get(selectedSurveyYear)}</strong>
      <span>${activeFilterSummary}</span>
      <span><strong>${fmtInt(mapTotal)}</strong> facilities; <strong>${fmtRate(mapRateTotal)}</strong> per 100,000 residents</span>
      <span class="qa-badge">${selectedYearMeta.qa_status}: ${selectedYearMeta.reviewed_listings}/${selectedYearMeta.gold_target} baseline reviews</span>
    </div>`
  : html`<div class="callout callout-style-default callout-warning">
      <div class="callout-body-container callout-body">
        <strong>Not available for ${yearLabels.get(selectedSurveyYear)}.</strong>
        At least one selected characteristic was not asked or cannot be harmonized for this year. This is shown as missing, not zero.
      </div>
    </div>`
Plot.plot({
  width: 960,
  height: 555,
  projection: "albers-usa",
  color: {
    scheme: "blues",
    label: perCapita ? "Facilities per 100,000" : "Facility count",
    legend: true,
    unknown: "#e5e7eb"
  },
  marks: [
    Plot.geo(stateFeatures, {
      fill: (feature) => mapIsAvailable
        ? mapByFips.get(String(feature.id).padStart(2, "0"))?.value
        : NaN,
      stroke: "#ffffff",
      strokeWidth: 0.8,
      title: (feature) => {
        const fips = String(feature.id).padStart(2, "0")
        const row = mapByFips.get(fips)
        if (!mapIsAvailable || !row) return `${feature.properties.name}\nNot available for this selection`
        return `${feature.properties.name}
${yearLabels.get(selectedSurveyYear)}
Facilities: ${fmtInt(row.count)}
Rate: ${fmtRate(row.rate)} per 100,000
Population: ${fmtInt(row.population)}
QA: ${selectedYearMeta.qa_status}
Filters: ${activeFilterSummary}`
      }
    })
  ]
})

Per-capita values use annual Census resident population estimates. Headline totals cover the 50 states and DC. Territories remain in the underlying parser outputs and are reported separately in the Overview metadata.

Compare the current facility selection over time

The filters selected in the State Explorer also govern this tab.

viewof comparisonStartYear
viewof comparisonEndYear
viewof scaleMode
viewof selectedOverlays
viewof changeMetric
comparisonYearLo = Math.min(comparisonStartYear, comparisonEndYear)
comparisonYearHi = Math.max(comparisonStartYear, comparisonEndYear)
comparisonYears = surveyYears.filter((year) => year >= comparisonYearLo && year <= comparisonYearHi)
comparisonUsesRaw = rawCodeSelection.length > 0
comparisonRows = comparisonUsesRaw
  ? (await Promise.all(comparisonYears.map((year) =>
      d3.csv(`data/facility_dashboard_${year}.csv`, d3.autoType)
    ))).flat()
  : filterCube.filter((d) => d.survey_year >= comparisonYearLo && d.survey_year <= comparisonYearHi)

rowWeight = (row) => comparisonUsesRaw ? 1 : (+row.facility_count || 0)
comparisonPasses = (row) => comparisonUsesRaw ? rowPassesShard(row) : rowPassesHarmonized(row)
geographyRows = (rows, geography) => {
  if (geography === "US") return rows
  const [kind, value] = geography.split(":")
  return kind === "Region"
    ? rows.filter((d) => d.census_region === value)
    : rows.filter((d) => d.state === value)
}
geographyPopulation = (year, geography) => {
  const rows = population.filter((d) => d.survey_year === year)
  if (geography === "US") return d3.sum(rows, (d) => d.population)
  const [kind, value] = geography.split(":")
  if (kind === "State") return populationByYearState.get(`${year}|${value}`)?.population ?? NaN
  const statesInRegion = new Set(
    stateCounts.filter((d) => d.survey_year === year && d.census_region === value).map((d) => d.state)
  )
  return d3.sum(rows.filter((d) => statesInRegion.has(d.state)), (d) => d.population)
}

trendRows = comparisonYears.flatMap((year) => {
  const available = selectionAvailable(year)
  const yearRows = available
    ? comparisonRows.filter((d) => d.survey_year === year && comparisonPasses(d))
    : []
  return overlaySelection.map((geography) => {
    const count = available
      ? d3.sum(geographyRows(yearRows, geography), rowWeight)
      : NaN
    const denominator = geographyPopulation(year, geography)
    const rate = available && denominator > 0 ? count / denominator * 100000 : NaN
    return {
      survey_year: year,
      geography,
      geography_label: overlayLabel(geography),
      count,
      population: denominator,
      rate,
      value: perCapita ? rate : count,
      available,
      qa_status: yearMetadata.get(year)?.qa_status ?? "Not reviewed"
    }
  })
})
trendYLabel = perCapita ? "Facilities per 100,000 residents" : "Facility count"
trendChart = Plot.plot({
  width: 960,
  height: 455,
  marginLeft: 85,
  marginRight: 25,
  x: {label: "Survey year", tickFormat: d3.format("d")},
  y: {label: trendYLabel, grid: true},
  color: {legend: true},
  marks: [
    Plot.ruleY([0], {stroke: "#cbd5e1"}),
    Plot.ruleX([2020], {stroke: "#b91c1c", strokeDasharray: "5,4"}),
    Plot.line(trendRows, {
      x: "survey_year",
      y: "value",
      stroke: "geography_label",
      strokeWidth: 2.4
    }),
    Plot.dot(trendRows, {
      x: "survey_year",
      y: "value",
      stroke: "geography_label",
      r: 3,
      title: (d) => `${d.geography_label}
${yearLabels.get(d.survey_year)}
${trendYLabel}: ${perCapita ? fmtRate(d.value) : fmtInt(d.value)}
Population: ${fmtInt(d.population)}
QA: ${d.qa_status}${d.available ? "" : "\nSelected characteristic not available"}`
    })
  ]
})
normalizeMulti(selectedOverlays).length > 5
  ? html`<div class="callout callout-style-default callout-warning"><div class="callout-body-container callout-body">Only the first five selected geographies are displayed.</div></div>`
  : null
trendChart
safeFilename = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
wrapCanvasText = (context, text, x, y, maxWidth, lineHeight, maxLines = 3) => {
  const words = text.split(/\s+/)
  const lines = []
  let line = ""
  for (const word of words) {
    const test = line ? `${line} ${word}` : word
    if (context.measureText(test).width > maxWidth && line) {
      lines.push(line)
      line = word
    } else {
      line = test
    }
  }
  if (line) lines.push(line)
  lines.slice(0, maxLines).forEach((value, index) => context.fillText(value, x, y + index * lineHeight))
}
trendDownloadPng = {
  const button = html`<button class="btn btn-primary" type="button">Download trend PNG</button>`
  button.onclick = async () => {
    const canvas = document.createElement("canvas")
    canvas.width = 1600
    canvas.height = 900
    const context = canvas.getContext("2d")
    context.fillStyle = "#ffffff"
    context.fillRect(0, 0, canvas.width, canvas.height)
    context.fillStyle = "#10253f"
    context.font = "bold 36px Arial, sans-serif"
    context.fillText("Historical SAMHSA Treatment Facility Listings", 70, 62)
    context.font = "22px Arial, sans-serif"
    context.fillText(`${comparisonYearLo}–${comparisonYearHi} survey years · ${trendYLabel}`, 70, 100)
    context.fillStyle = "#334155"
    context.font = "17px Arial, sans-serif"
    wrapCanvasText(context, `Filters: ${activeFilterSummary}`, 70, 132, 1460, 22, 2)

    const clone = trendChart.cloneNode(true)
    clone.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    const svgBlob = new Blob([new XMLSerializer().serializeToString(clone)], {type: "image/svg+xml;charset=utf-8"})
    const svgUrl = URL.createObjectURL(svgBlob)
    const image = new Image()
    await new Promise((resolve, reject) => {
      image.onload = resolve
      image.onerror = reject
      image.src = svgUrl
    })
    context.drawImage(image, 70, 185, 1460, 610)
    URL.revokeObjectURL(svgUrl)

    context.fillStyle = "#7c2d12"
    context.font = "bold 16px Arial, sans-serif"
    context.fillText("PRELIMINARY: year-level manual validation is incomplete.", 70, 830)
    context.fillStyle = "#475569"
    context.font = "14px Arial, sans-serif"
    context.fillText("Source: SAMHSA historical treatment facility directories; Census resident population estimates.", 70, 856)
    context.fillText(`Generated ${new Date().toISOString().slice(0, 10)} · econdavidhall.com`, 70, 878)

    const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"))
    const href = URL.createObjectURL(blob)
    const link = document.createElement("a")
    link.href = href
    link.download = `samhsa-facility-trend-${comparisonYearLo}-${comparisonYearHi}-${safeFilename(scaleMode)}.png`
    link.click()
    setTimeout(() => URL.revokeObjectURL(href), 1000)
  }
  return button
}
trendDownloadCsv = {
  const csv = d3.csvFormat(trendRows.map((d) => ({
    survey_year: d.survey_year,
    directory_year: yearMetadata.get(d.survey_year)?.directory_year,
    geography: d.geography_label,
    facility_count: Number.isFinite(d.count) ? d.count : "",
    population: Number.isFinite(d.population) ? d.population : "",
    facilities_per_100000: Number.isFinite(d.rate) ? d.rate : "",
    available: d.available,
    qa_status: d.qa_status
  })))
  const blob = new Blob([csv], {type: "text/csv;charset=utf-8"})
  const href = URL.createObjectURL(blob)
  return html`<a class="btn btn-outline-primary" href="${href}" download="samhsa-facility-trend-${comparisonYearLo}-${comparisonYearHi}.csv">Download plotted CSV</a>`
}
html`<div class="dashboard-download-row">${trendDownloadPng}${trendDownloadCsv}</div>`

State Change

changeStartAvailable = selectionAvailable(comparisonYearLo)
changeEndAvailable = selectionAvailable(comparisonYearHi)
changeAvailable = changeStartAvailable && changeEndAvailable
yearFilteredStateCounts = (year) => {
  if (!selectionAvailable(year)) return new Map()
  const rows = comparisonRows.filter((d) => d.survey_year === year && comparisonPasses(d))
  return d3.rollup(rows, (values) => d3.sum(values, rowWeight), (d) => d.state)
}
startStateCounts = yearFilteredStateCounts(comparisonYearLo)
endStateCounts = yearFilteredStateCounts(comparisonYearHi)
changeRows = stateOptions.map((state) => {
  const start = startStateCounts.get(state) ?? 0
  const end = endStateCounts.get(state) ?? 0
  const startPopulation = populationByYearState.get(`${comparisonYearLo}|${state}`)?.population ?? NaN
  const endPopulation = populationByYearState.get(`${comparisonYearHi}|${state}`)?.population ?? NaN
  const startRate = startPopulation > 0 ? start / startPopulation * 100000 : NaN
  const endRate = endPopulation > 0 ? end / endPopulation * 100000 : NaN
  const absolute = end - start
  const percent = start > 0 ? absolute / start * 100 : NaN
  const rateChange = endRate - startRate
  const value = changeMetric === "Absolute count change"
    ? absolute
    : changeMetric === "Percent change"
      ? percent
      : rateChange
  return {
    state,
    state_fips: metadata.state_fips[state],
    start,
    end,
    startRate,
    endRate,
    absolute,
    percent,
    rateChange,
    value
  }
})
changeByFips = new Map(changeRows.map((d) => [d.state_fips, d]))
changeAvailable
  ? Plot.plot({
      width: 960,
      height: 555,
      projection: "albers-usa",
      color: {
        type: "diverging",
        scheme: "RdBu",
        reverse: true,
        pivot: 0,
        label: changeMetric,
        legend: true,
        unknown: "#e5e7eb"
      },
      marks: [
        Plot.geo(stateFeatures, {
          fill: (feature) => changeByFips.get(String(feature.id).padStart(2, "0"))?.value,
          stroke: "#ffffff",
          strokeWidth: 0.8,
          title: (feature) => {
            const row = changeByFips.get(String(feature.id).padStart(2, "0"))
            if (!row) return feature.properties.name
            return `${feature.properties.name}
${yearLabels.get(comparisonYearLo)}: ${fmtInt(row.start)} (${fmtRate(row.startRate)} per 100,000)
${yearLabels.get(comparisonYearHi)}: ${fmtInt(row.end)} (${fmtRate(row.endRate)} per 100,000)
Count change: ${fmtChange(row.absolute)}
Percent change: ${fmtChange(row.percent, "%")}
Rate change: ${fmtChange(row.rateChange)} per 100,000`
          }
        })
      ]
    })
  : html`<div class="callout callout-style-default callout-warning"><div class="callout-body-container callout-body">
      The selected filters are not available in both comparison years, so change is shown as missing rather than zero.
    </div></div>`

Percent change is unavailable when a state has zero matching facilities in the starting year.

Not yet activated

County assignment still requires Census street geocoding and a minimum 90% high-confidence match rate. ZIP/county fallback assignments will be visibly distinguished when this tab is activated. State geography is available now because it is printed directly in the source directories.

How filters work

  • Empty filters include all listings.
  • Multiple selections within ownership, center type, setting, and payment use OR logic.
  • Different filter groups combine using AND logic.
  • Service families and original codes use the displayed All/Any choice.
  • A characteristic that was not asked in a year produces a gap or unavailable map, not a zero.

Population and comparability

Rates use annual Census resident population estimates for the 50 states and DC. Survey year drives the charts; directory year remains visible in labels. The 2020 survey / 2021 directory transition is marked because the N-SSATS and N-SUMHSS series are not directly comparable.

Preliminary QA

  • All 22 PDFs have structurally complete parser outputs.

  • Manual gold review has begun with the 1998 calibration year.

  • Every analytical tab retains a preliminary-data warning.

  • Address-level records, historical phone numbers, and county geography remain outside this preliminary dashboard.

  • Dataset and methodology page

  • Parser repository

  • SAMHSA public-use files

  • Census population estimates

Harmonized Filter Crosswalk

Inputs.table(crosswalk, {
  columns: ["characteristic", "harmonized_label", "original_code", "notes"],
  header: {
    characteristic: "Filter group",
    harmonized_label: "Dashboard label",
    original_code: "SAMHSA code",
    notes: "Interpretation"
  },
  rows: 15
})
Get new posts by email:
Powered by follow.it
  1. 2026 David Hall

dhall7@uoregon.edu

GitHub | X/Twitter | Built with Quarto