David Hall
  • About
  • Research
  • Teaching
  • Data
  • Blog
  • CV

Historical Treatment Facilities

Explore historical SAMHSA treatment facility surveys and directory listings.
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")

metadata = await FileAttachment("data/dashboard/dashboard_metadata.json").json()
trends = await FileAttachment("data/dashboard/survey_trends.csv").csv({typed: true})
directoryStatus = await FileAttachment("data/dashboard/directory_year_status.csv").csv({typed: true})
population = await FileAttachment("data/dashboard/state_population.csv").csv({typed: true})
downloads = await FileAttachment("data/dashboard/download_catalog.csv").csv({typed: true})
cbpComparison = await FileAttachment("data/dashboard/cbp_comparison_trends.csv").csv({typed: true})

statesTopo = await d3.json("https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json")
countiesTopo = await d3.json("https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json")
stateFeatures = topojson.feature(statesTopo, statesTopo.objects.states).features
countyFeatures = topojson.feature(countiesTopo, countiesTopo.objects.counties).features
stateAbbrByFips = new Map(Object.entries(metadata.state_fips).map(([state, fips]) => [String(fips).padStart(2, "0"), state]))
stateNameByFips = new Map(stateFeatures.map((feature) => [String(feature.id).padStart(2, "0"), feature.properties.name]))
stateOptions = Object.keys(metadata.state_fips).sort()
populationByKey = new Map(population.map((row) => [`${row.survey_year}|${row.state}`, +row.population]))
statusByYear = new Map(directoryStatus.map((row) => [+row.survey_year, row]))
surveyYears = metadata.survey_years.map(Number).sort((a, b) => a - b)
directoryYears = metadata.directory_survey_years.map(Number).sort((a, b) => a - b)

fmtInt = (value) => Number.isFinite(+value)
  ? new Intl.NumberFormat("en-US", {maximumFractionDigits: 0}).format(+value)
  : "Not available"
fmtRate = (value) => Number.isFinite(+value)
  ? new Intl.NumberFormat("en-US", {maximumFractionDigits: 1}).format(+value)
  : "Not available"
fmtPct = (value) => Number.isFinite(+value)
  ? new Intl.NumberFormat("en-US", {style: "percent", maximumFractionDigits: 1}).format(+value)
  : "Not available"
normalizeMulti = (value) => value == null ? [] : Array.isArray(value) ? value : [value]

Treatment facility data Quality assurance is ongoing. Historical data, not a current treatment locator.

  • Overview
  • Trends
  • State Explorer
  • County Explorer
  • CBP Comparison
  • Downloads & Methods

Source: N-SSATS/N-SUMHSS public-use facility surveys. The 2021 redesign is shown as a break.

viewof overviewYear = Inputs.select(surveyYears, {
  label: "Survey year",
  value: d3.max(surveyYears),
  format: (year) => String(year)
})
nationalValue = (metric, category, year = overviewYear) => trends.find((row) =>
  row.geography_type === "national" &&
  row.geography === "US" &&
  row.survey_year === year &&
  row.metric === metric &&
  row.category === category
)
overviewCards = [
  ["Total facilities", nationalValue("Total facilities", "All facilities")],
  ["OTP", nationalValue("OTP status", "OTP")],
  ["Non-OTP", nationalValue("OTP status", "Non-OTP")],
  ["For-profit", nationalValue("Ownership", "For-profit")],
  ["Nonprofit", nationalValue("Ownership", "Nonprofit")],
  ["Government", nationalValue("Ownership", "Government")]
]
html`<div class="stat-grid">${overviewCards.map(([label, row]) => html`
  <div class="stat-card">
    <span>${label}</span>
    <strong>${row?.available ? fmtInt(row.facility_count) : "Not available"}</strong>
  </div>
`)}</div>`
viewof overviewMetric = Inputs.select(metadata.trend_metrics, {
  label: "Chart",
  value: "Total facilities"
})
overviewRowsRaw = trends.filter((row) =>
  row.geography_type === "national" &&
  row.geography === "US" &&
  row.metric === overviewMetric &&
  row.available
)
segmentSeries = (rows, key = (row) => row.category) => {
  const output = []
  for (const [series, values] of d3.groups(rows, key)) {
    let segment = 0
    let previous = null
    for (const row of values.slice().sort((a, b) => a.survey_year - b.survey_year)) {
      if (previous !== null && (row.survey_year - previous > 1 || (previous <= 2020 && row.survey_year >= 2021))) segment += 1
      output.push({...row, seriesSegment: `${series}|${segment}`})
      previous = row.survey_year
    }
  }
  return output
}
overviewRows = segmentSeries(overviewRowsRaw)
overviewChart = Plot.plot({
  width: 980,
  height: 420,
  marginLeft: 78,
  x: {label: "Survey year", tickFormat: d3.format("d")},
  y: {label: "Facilities", grid: true},
  color: {legend: overviewMetric !== "Total facilities"},
  marks: [
    Plot.ruleY([0], {stroke: "#d8d1d4"}),
    Plot.ruleX([2020.5], {stroke: "#5b1f2e", strokeDasharray: "5,4"}),
    Plot.line(overviewRows, {x: "survey_year", y: "facility_count", stroke: "category", z: "seriesSegment", strokeWidth: 2.5}),
    Plot.dot(overviewRows, {
      x: "survey_year", y: "facility_count", stroke: "category", r: 3,
      title: (row) => `${row.category}\n${row.survey_year}\n${fmtInt(row.facility_count)} facilities`
    })
  ]
})
html`<div class="chart-wrap">${overviewChart}</div>`

Source: Public-use facility surveys. Choose one characteristic and compare up to three geographies.

viewof trendMetric = Inputs.select(metadata.trend_metrics, {
  label: "Characteristic",
  value: "Ownership"
})
trendCategories = [...new Set(trends.filter((row) => row.metric === trendMetric).map((row) => row.category))]
viewof trendCategory = Inputs.select(trendCategories, {
  label: "Series",
  value: trendCategories[0]
})
viewof trendScale = Inputs.radio(["Facility count", "Per 100,000"], {
  label: "Measure",
  value: "Facility count"
})
geographyOptions = [
  "national|US",
  ...metadata.regions.map((region) => `region|${region}`),
  ...stateOptions.map((state) => `state|${state}`)
]
geographyLabel = (value) => {
  const [type, geography] = value.split("|")
  if (type === "national") return "United States"
  if (type === "region") return `${geography} region`
  return stateNameByFips.get(metadata.state_fips[geography]) ?? geography
}
comparisonOptions = ["none|", ...geographyOptions]
comparisonLabel = (value) => value === "none|" ? "None" : geographyLabel(value)
viewof trendGeographyPrimary = Inputs.select(geographyOptions, {
  label: "Primary geography",
  value: "national|US",
  format: geographyLabel
})
viewof trendGeographyCompare1 = Inputs.select(comparisonOptions, {
  label: "Comparison 1",
  value: "none|",
  format: comparisonLabel
})
viewof trendGeographyCompare2 = Inputs.select(comparisonOptions, {
  label: "Comparison 2",
  value: "none|",
  format: comparisonLabel
})
trendGeographySelection = [...new Set([
  trendGeographyPrimary, trendGeographyCompare1, trendGeographyCompare2
].filter((value) => value !== "none|"))]
trendPerCapita = trendScale === "Per 100,000"
trendChartRowsRaw = trendGeographySelection.flatMap((selection) => {
  const [type, geography] = selection.split("|")
  return trends.filter((row) =>
    row.geography_type === type && row.geography === geography &&
    row.metric === trendMetric && row.category === trendCategory && row.available
  ).map((row) => ({
    ...row,
    geographyLabel: geographyLabel(selection),
    value: trendPerCapita ? row.facilities_per_100000 : row.facility_count
  }))
})
trendChartRows = segmentSeries(trendChartRowsRaw, (row) => row.geographyLabel)
trendYLabel = trendPerCapita ? "Facilities per 100,000 residents" : "Facility count"
trendChart = Plot.plot({
  width: 980,
  height: 450,
  marginLeft: 82,
  x: {label: "Survey year", tickFormat: d3.format("d")},
  y: {label: trendYLabel, grid: true},
  color: {legend: true},
  marks: [
    Plot.ruleY([0], {stroke: "#d8d1d4"}),
    Plot.ruleX([2020.5], {stroke: "#5b1f2e", strokeDasharray: "5,4"}),
    Plot.line(trendChartRows, {x: "survey_year", y: "value", stroke: "geographyLabel", z: "seriesSegment", strokeWidth: 2.5}),
    Plot.dot(trendChartRows, {
      x: "survey_year", y: "value", stroke: "geographyLabel", r: 3,
      title: (row) => `${row.geographyLabel}\n${row.survey_year}\n${trendYLabel}: ${trendPerCapita ? fmtRate(row.value) : fmtInt(row.value)}`
    })
  ]
})
html`<div class="chart-wrap">${trendChart}</div>`
safeFile = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
trendCsv = {
  const csv = d3.csvFormat(trendChartRowsRaw.map((row) => ({
    survey_year: row.survey_year,
    geography: row.geographyLabel,
    metric: row.metric,
    category: row.category,
    facility_count: row.facility_count,
    population: row.population ?? "",
    facilities_per_100000: row.facilities_per_100000 ?? "",
    source: row.source
  })))
  const href = URL.createObjectURL(new Blob([csv], {type: "text/csv;charset=utf-8"}))
  return html`<a class="btn btn-outline-primary" href="${href}" download="treatment-facility-trend.csv">Download plotted CSV</a>`
}
trendPng = {
  const button = html`<button class="btn btn-primary" type="button">Download 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, 1600, 900)
    context.fillStyle = "#35101b"
    context.font = "bold 36px Arial, sans-serif"
    context.fillText(`${trendMetric}: ${trendCategory}`, 70, 62)
    context.fillStyle = "#4f4650"
    context.font = "20px Arial, sans-serif"
    context.fillText(`${trendYLabel} | N-SSATS/N-SUMHSS public-use facility surveys`, 70, 100)
    const clone = trendChart.cloneNode(true)
    clone.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    const blob = new Blob([new XMLSerializer().serializeToString(clone)], {type: "image/svg+xml;charset=utf-8"})
    const url = URL.createObjectURL(blob)
    const image = new Image()
    await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = url })
    context.drawImage(image, 70, 155, 1460, 620)
    URL.revokeObjectURL(url)
    context.fillStyle = "#5b1f2e"
    context.font = "bold 16px Arial, sans-serif"
    context.fillText("QUALITY ASSURANCE IS ONGOING. HISTORICAL DATA ONLY.", 70, 825)
    context.fillStyle = "#6b5f67"
    context.font = "14px Arial, sans-serif"
    context.fillText(`Generated ${new Date().toISOString().slice(0, 10)} | PUF trends are not silently combined with directory listings.`, 70, 858)
    const png = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"))
    const href = URL.createObjectURL(png)
    const link = document.createElement("a")
    link.href = href
    link.download = `treatment-facility-${safeFile(trendMetric)}-${safeFile(trendCategory)}.png`
    link.click()
    setTimeout(() => URL.revokeObjectURL(href), 1000)
  }
  return button
}
html`<div class="download-row">${trendPng}${trendCsv}</div>`

Source: Published SAMHSA directory listings. Filters describe listed facilities, not the PUF trend universe.

viewof directoryYear = Inputs.select(directoryYears, {
  label: "Survey year",
  value: d3.max(directoryYears),
  format: (year) => `${year} survey (${statusByYear.get(year)?.directory_year ?? ""} directory)`
})
viewof stateScale = Inputs.radio(["Facility count", "Per 100,000"], {
  label: "Map measure",
  value: "Facility count"
})
viewof selectedState = Inputs.select(stateOptions, {
  label: "Selected state",
  value: "OR",
  format: (state) => stateNameByFips.get(metadata.state_fips[state]) ?? state
})
toBoolean = (value) => String(value).toLowerCase() === "true"
toNullableBoolean = (value) => value === "" || value == null ? null : toBoolean(value)
directoryRows = await d3.csv(`data/dashboard/directory_facilities_${directoryYear}.csv`, (row) => ({
  ...row,
  survey_year: +row.survey_year,
  directory_year: +row.directory_year,
  state_fips: String(row.state_fips || "").padStart(2, "0"),
  county_fips: row.county_fips ? String(row.county_fips).padStart(5, "0") : "",
  accepts_medicaid: toNullableBoolean(row.accepts_medicaid),
  accepts_medicare: toNullableBoolean(row.accepts_medicare),
  accepts_private_insurance: toNullableBoolean(row.accepts_private_insurance),
  center_type_substance_use: toNullableBoolean(row.center_type_substance_use),
  center_type_detoxification: toNullableBoolean(row.center_type_detoxification),
  center_type_transitional_housing: toNullableBoolean(row.center_type_transitional_housing),
  center_type_mental_health: toNullableBoolean(row.center_type_mental_health),
  center_type_cooccurring: toNullableBoolean(row.center_type_cooccurring),
  setting_outpatient: toNullableBoolean(row.setting_outpatient),
  setting_residential: toNullableBoolean(row.setting_residential),
  setting_hospital: toNullableBoolean(row.setting_hospital),
  service_otp: toNullableBoolean(row.service_otp),
  service_methadone: toNullableBoolean(row.service_methadone),
  service_buprenorphine: toNullableBoolean(row.service_buprenorphine),
  service_naltrexone: toNullableBoolean(row.service_naltrexone),
  service_telehealth: toNullableBoolean(row.service_telehealth),
  otp_available: toBoolean(row.otp_available),
  has_parser_warning: toBoolean(row.has_parser_warning)
}))
currentDirectoryStatus = statusByYear.get(directoryYear)
availableOwnership = currentDirectoryStatus?.ownership_coverage >= 0.95
  ? [...new Set(directoryRows.map((row) => row.ownership_group).filter(Boolean))].sort()
  : []
Filter directory listings
viewof ownershipFilters = Inputs.checkbox(availableOwnership, {
  label: availableOwnership.length ? "Ownership" : "Ownership not available for this year"
})
centerChoices = [
  ["Substance use", "center_type_substance_use"],
  ["Detoxification", "center_type_detoxification"],
  ["Transitional housing", "center_type_transitional_housing"],
  ["Mental health", "center_type_mental_health"],
  ["Co-occurring", "center_type_cooccurring"]
]
availableCenterChoices = centerChoices.filter((choice) =>
  directoryRows.some((row) => row[choice[1]] !== null)
)
viewof centerFilters = Inputs.checkbox(availableCenterChoices.map((row) => row[1]), {
  label: availableCenterChoices.length ? "Center type" : "Center type not available for this year",
  format: (value) => centerChoices.find((row) => row[1] === value)?.[0] ?? value
})
settingChoices = [
  ["Outpatient", "setting_outpatient"],
  ["Residential", "setting_residential"],
  ["Hospital inpatient", "setting_hospital"]
]
availableSettingChoices = settingChoices.filter((choice) =>
  directoryRows.some((row) => row[choice[1]] !== null)
)
viewof settingFilters = Inputs.checkbox(availableSettingChoices.map((row) => row[1]), {
  label: availableSettingChoices.length ? "Care setting" : "Care setting not available for this year",
  format: (value) => settingChoices.find((row) => row[1] === value)?.[0] ?? value
})
paymentChoices = [
  ["Medicaid", "accepts_medicaid"],
  ["Medicare", "accepts_medicare"],
  ["Private insurance", "accepts_private_insurance"]
]
availablePaymentChoices = paymentChoices.filter((choice) =>
  directoryRows.some((row) => row[choice[1]] !== null)
)
viewof paymentFilters = Inputs.checkbox(availablePaymentChoices.map((row) => row[1]), {
  label: availablePaymentChoices.length ? "Payment" : "Payment not available for this year",
  format: (value) => paymentChoices.find((row) => row[1] === value)?.[0] ?? value
})
serviceChoices = [
  ["OTP", "service_otp"],
  ["Methadone", "service_methadone"],
  ["Buprenorphine", "service_buprenorphine"],
  ["Naltrexone", "service_naltrexone"],
  ["Telehealth", "service_telehealth"]
]
availableServiceChoices = serviceChoices.filter((choice) =>
  directoryRows.some((row) => row[choice[1]] !== null)
)
viewof serviceFilters = Inputs.checkbox(availableServiceChoices.map((row) => row[1]), {
  label: availableServiceChoices.length ? "Services" : "Services not available for this year",
  format: (value) => serviceChoices.find((row) => row[1] === value)?.[0] ?? value
})
viewof serviceLogic = Inputs.radio(["Offers all selected", "Offers any selected"], {
  label: "Multiple services",
  value: "Offers all selected"
})
resetFilters = {
  const button = html`<button class="btn btn-sm btn-outline-secondary" type="button">Reset filters</button>`
  button.onclick = () => document.querySelectorAll(".filter-panel input[type=checkbox]:checked").forEach((input) => input.click())
  return button
}
matchesAnyField = (row, selected) => selected.length === 0 || selected.some((field) => row[field] === true)
matchesServices = (row, selected) => selected.length === 0 || (
  serviceLogic === "Offers all selected"
    ? selected.every((field) => row[field] === true)
    : selected.some((field) => row[field] === true)
)
rowPasses = (row) => (
  (normalizeMulti(ownershipFilters).length === 0 || normalizeMulti(ownershipFilters).includes(row.ownership_group)) &&
  matchesAnyField(row, normalizeMulti(centerFilters)) &&
  matchesAnyField(row, normalizeMulti(settingFilters)) &&
  matchesAnyField(row, normalizeMulti(paymentFilters)) &&
  matchesServices(row, normalizeMulti(serviceFilters))
)
filteredDirectoryRows = directoryRows.filter(rowPasses)
activeFilters = [
  ...normalizeMulti(ownershipFilters),
  ...normalizeMulti(centerFilters).map((value) => centerChoices.find((row) => row[1] === value)?.[0]),
  ...normalizeMulti(settingFilters).map((value) => settingChoices.find((row) => row[1] === value)?.[0]),
  ...normalizeMulti(paymentFilters).map((value) => paymentChoices.find((row) => row[1] === value)?.[0]),
  ...normalizeMulti(serviceFilters).map((value) => serviceChoices.find((row) => row[1] === value)?.[0])
].filter(Boolean)
stateCountsFiltered = d3.rollup(filteredDirectoryRows, (rows) => rows.length, (row) => row.state)
stateMapData = stateFeatures
  .filter((feature) => stateAbbrByFips.has(String(feature.id).padStart(2, "0")))
  .map((feature) => {
    const fips = String(feature.id).padStart(2, "0")
    const state = stateAbbrByFips.get(fips)
    const count = stateCountsFiltered.get(state) ?? 0
    const denominator = populationByKey.get(`${directoryYear}|${state}`)
    const rate = denominator > 0 ? count / denominator * 100000 : NaN
    return {...feature, state, count, denominator, rate, value: stateScale === "Per 100,000" ? rate : count}
  })
stateMap = Plot.plot({
  width: 980,
  height: 560,
  projection: "albers-usa",
  color: {scheme: "purples", legend: true, label: stateScale},
  marks: [
    Plot.geo(stateMapData, {
      fill: "value", stroke: "white",
      title: (row) => `${row.properties.name}\n${fmtInt(row.count)} facilities\n${fmtRate(row.rate)} per 100,000`
    })
  ]
})
selectedStateRows = filteredDirectoryRows.filter((row) => row.state === selectedState)
selectedStateOtpAvailable = toBoolean(currentDirectoryStatus?.otp_available)
selectedStateCards = [
  ["Facilities", selectedStateRows.length],
  ["OTP", selectedStateOtpAvailable ? selectedStateRows.filter((row) => row.service_otp === true).length : null],
  ["For-profit", availableOwnership.length ? selectedStateRows.filter((row) => row.ownership_group === "For-profit").length : null],
  ["Nonprofit", availableOwnership.length ? selectedStateRows.filter((row) => row.ownership_group === "Nonprofit").length : null],
  ["Government", availableOwnership.length ? selectedStateRows.filter((row) => row.ownership_group === "Government").length : null]
]
html`<div class="filter-summary">${activeFilters.length ? activeFilters.map((value) => html`<span>${value}</span>`) : html`<span>No filters</span>`}</div>`
html`<div class="chart-wrap">${stateMap}</div>`
html`<h3>${stateNameByFips.get(metadata.state_fips[selectedState]) ?? selectedState}</h3>
<div class="stat-grid compact">${selectedStateCards.map(([label, value]) => html`
  <div class="stat-card"><span>${label}</span><strong>${value === null ? "Not available" : fmtInt(value)}</strong></div>
`)}</div>`

Source: Published SAMHSA directory listings. Preliminary geography.

viewof countyState = Inputs.select(stateOptions, {
  label: "State",
  value: "OR",
  format: (state) => stateNameByFips.get(metadata.state_fips[state]) ?? state
})
html`<p class="county-context">
  <strong>${directoryYear} survey year</strong>
  <span>${activeFilters.length ? activeFilters.join(", ") : "No facility filters"}</span>
  <span>Change year and facility filters in State Explorer.</span>
</p>`
countyStateFips = String(metadata.state_fips[countyState]).padStart(2, "0")
countyStateRows = filteredDirectoryRows.filter((row) => row.state === countyState)
countyAssignedRows = countyStateRows.filter((row) => row.county_fips)
countyCounts = d3.rollup(countyAssignedRows, (rows) => rows.length, (row) => row.county_fips)
countyMapData = countyFeatures
  .filter((feature) => String(feature.id).padStart(5, "0").startsWith(countyStateFips))
  .map((feature) => {
    const fips = String(feature.id).padStart(5, "0")
    return {...feature, fips, count: countyCounts.get(fips) ?? 0}
  })
countyHigh = countyAssignedRows.filter((row) => row.geocode_confidence === "high").length
countyFallback = countyAssignedRows.filter((row) => row.geocode_method === "zip_crosswalk").length
countyUnassigned = countyStateRows.length - countyAssignedRows.length
countyMap = Plot.plot({
  width: 980,
  height: 590,
  projection: {type: "mercator", domain: {type: "FeatureCollection", features: countyMapData}},
  color: {scheme: "purples", legend: true, label: "Facility count"},
  marks: [
    Plot.geo(countyMapData, {
      fill: "count", stroke: "white",
      title: (row) => `County FIPS ${row.fips}\n${fmtInt(row.count)} facilities`
    })
  ]
})
html`<div class="stat-grid compact">
  <div class="stat-card"><span>Filtered state total</span><strong>${fmtInt(countyStateRows.length)}</strong></div>
  <div class="stat-card"><span>County assigned</span><strong>${fmtInt(countyAssignedRows.length)}</strong></div>
  <div class="stat-card"><span>Unassigned</span><strong>${fmtInt(countyUnassigned)}</strong></div>
  <div class="stat-card"><span>High confidence</span><strong>${fmtInt(countyHigh)}</strong></div>
  <div class="stat-card"><span>ZIP fallback</span><strong>${fmtInt(countyFallback)}</strong></div>
</div>`
html`<div class="chart-wrap">${countyMap}</div>`

Sources: County Business Patterns establishments, published SAMHSA directory listings, and N-SSATS/N-SUMHSS public-use survey records. Counts are shown separately.

viewof comparisonGeography = Inputs.select(["US", ...stateOptions], {
  label: "Geography",
  value: "US",
  format: (value) => value === "US" ? "United States" : value
})
comparisonGeoType = comparisonGeography === "US" ? "national" : "state"
comparisonRows = cbpComparison.filter((row) =>
  row.geography_type === comparisonGeoType &&
  row.geography === comparisonGeography
)
comparisonSurveyRows = trends.filter((row) =>
  row.metric === "Total facilities" &&
  row.category === "All facilities" &&
  row.geography_type === comparisonGeoType &&
  row.geography === comparisonGeography &&
  row.available
)
comparisonSeries = [
  ...comparisonSurveyRows.map((row) => ({
    year: +row.survey_year,
    value: +row.facility_count,
    source: "N-SSATS/N-SUMHSS survey"
  })),
  ...comparisonRows.map((row) => ({
    year: +row.year,
    value: +row.directory_count,
    source: "SAMHSA directory"
  })),
  ...comparisonRows.filter((row) => row.cbp_published_count != null && row.cbp_published_count !== "" && Number.isFinite(+row.cbp_published_count)).map((row) => ({
    year: +row.year,
    value: +row.cbp_published_count,
    source: +row.year <= 2016 ? "CBP establishments" : "CBP published cells"
  }))
]
comparisonBounds = comparisonRows.filter((row) =>
  +row.year >= 2017 &&
  row.cbp_lower_bound != null && row.cbp_lower_bound !== "" &&
  row.cbp_upper_bound != null && row.cbp_upper_bound !== "" &&
  Number.isFinite(+row.cbp_lower_bound) &&
  Number.isFinite(+row.cbp_upper_bound)
)
comparisonChart = Plot.plot({
  width: 920,
  height: 440,
  marginLeft: 70,
  x: {label: "Year", tickFormat: d3.format("d")},
  y: {label: "Facilities or establishments", grid: true},
  color: {legend: true},
  marks: [
    Plot.areaY(comparisonBounds, {
      x: "year",
      y1: "cbp_lower_bound",
      y2: "cbp_upper_bound",
      fill: "#9c7a32",
      fillOpacity: 0.18
    }),
    Plot.ruleX([2017], {stroke: "#6b5f67", strokeDasharray: "5,4"}),
    Plot.line(comparisonSeries, {x: "year", y: "value", stroke: "source"}),
    Plot.dot(comparisonSeries, {
      x: "year",
      y: "value",
      stroke: "source",
      r: 2.3,
      title: (row) => `${row.source}\n${row.year}: ${fmtInt(row.value)}`
    })
  ]
})
html`<div class="chart-wrap">${comparisonChart}</div>
<p class="compact-note">Through 2016, the CBP series uses published county cells for NAICS 621420 and 623220. Beginning in 2017, omitted county-industry cells are not treated as zero; the shaded area shows bounds allowing zero to two establishments per omitted cell.</p>`

Directory downloads and survey-reconstruction resources remain separate.

html`<div class="download-list">${downloads.map((row) => html`
  <a href="${row.url}" target="_blank" rel="noopener">
    <span>${row.dataset}</span><strong>${row.format}</strong>
  </a>
`)}</div>`
Which source should I use?

Use the public-use survey data for nationally comparable facility characteristics such as ownership, OTP status, treatment setting, payment acceptance, and services. Use the directory listings when address or county geography is required. Directory and survey counts can differ because the products have different inclusion and publication rules.

Survey redesign and missing values

N-SUMHSS replaced N-SSATS and N-MHSS in 2021. The dashboard retains substance-use and combined-focus N-SUMHSS facilities for substance-use trends and places a visible break at 2021. A characteristic that was not asked or cannot be harmonized is shown as unavailable, not zero.

County assignment

Street-level Census geocodes are treated as high confidence. ZIP-to-county assignments are retained as lower-confidence fallbacks. The county cards always show assigned and unassigned records so mapped totals can be reconciled to the filtered state total.

Reproducibility

This dashboard verifies a pinned checksum manifest from samhsa-treatment-facility-data, then generates only compact browser assets. Raw cleaning and PDF parsing are not duplicated here. The data repository and historical directory parser provide the complete provenance and QA files.

Get new posts by email:
Powered by follow.it
  1. 2026 David Hall

dhall7@uoregon.edu

Google Scholar | GitHub | X/Twitter | Built with Quarto