// Builds the king-county-overdose-deaths-by-place dataset: CSV tables + dataset JSON. // Inputs: research/whs/overdoses-data/*.csv (KCMEO dashboard pulls 2026-11-06), data/hic/hic_panel.csv const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const ROOT = path.resolve(__dirname, '..', '..'); const IN = path.join(ROOT, 'research', 'whs', 'overdoses-data'); const OUT = path.join(ROOT, 'facthub', 'out', 'housing-od'); fs.mkdirSync(OUT, { recursive: true }); function readCsv(file) { const text = fs.readFileSync(file, 'utf8').trim().split(/\r?\n/); const parse = (line) => { const out = []; let cur = ''; let q = false; for (const ch of line) { if (ch === '"') { q = !q; continue; } if (ch === ',' && !q) { out.push(cur); cur = ''; continue; } cur += ch; } out.push(cur); return out; }; const head = parse(text[0]); return text.slice(1).map((l) => { const v = parse(l); const o = {}; head.forEach((h, i) => (o[h] = v[i])); return o; }); } const toCsv = (rows, cols) => [cols.join(','), ...rows.map((r) => cols.map((c) => { const v = r[c]; if (v === null || v === undefined) return ''; const s = String(v); return /[",]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }).join(','))].join('\n') + '\n'; const SIT = { 'Housing service site': 'Subsidized and supportive housing (housing service site)', 'Privately owned or rented residence': 'Private home', 'Outdoors, homeless shelter, or vehicle': 'Outdoors, shelter or vehicle', 'Other temporary arrangement': 'Other temporary arrangement', 'Unknown': 'Unknown', }; const SITS = Object.keys(SIT); const round = (x, d) => Math.round(x * 10 ** d) / 10 ** d; // ---------- Table 1: Jan 1 - Nov 5 by living situation, 2019-2026 ---------- const ytdRaw = readCsv(path.join(IN, 'kc-me-ytd-jan1-nov5-by-year-2016-2026.csv')); const ytdTotal = {}; const ytdSit = {}; const ytdRegion = {}; for (const r of ytdRaw) { const y = +r.year; const d = +r.deaths_jan1_to_nov5; if (r.group === 'King County total') ytdTotal[y] = d; else if (r.group.startsWith('Region: ')) (ytdRegion[y] ||= {})[r.group.slice(8)] = d; else (ytdSit[y] ||= {})[r.group] = d; } const YEARS = [2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]; const t1 = []; for (const y of YEARS) { const named = SITS.filter((s) => s !== 'Unknown').reduce((a, s) => a + (ytdSit[y][s] || 0), 0); for (const s of SITS) { let v = ytdSit[y][s]; let vt = 'observed'; if (s === 'Unknown' && v === undefined) { v = ytdTotal[y] - named; vt = 'derived'; } t1.push({ year: y, period: 'Jan 1-Nov 5', living_situation: s, deaths: v, value_type: vt, county_total: ytdTotal[y] }); } const sum = SITS.reduce((a, s) => a + t1.find((r) => r.year === y && r.living_situation === s).deaths, 0); if (sum !== ytdTotal[y]) throw new Error('ytd sum mismatch ' + y); } // ---------- Table 2: full-year by living situation, 2019-2025 ---------- // this file has an unquoted living_situation containing commas: parse year,,deaths,share const annRaw = fs.readFileSync(path.join(IN, 'kc-me-living-situation-2019-2025.csv'), 'utf8').trim().split(/\r?\n/).slice(1).map((l) => { const p = l.split(','); return { year: p[0], living_situation: p.slice(1, -2).join(',').trim(), deaths: p[p.length - 2] }; }); const annTotRaw = readCsv(path.join(IN, 'kc-me-overdose-deaths-annual-2015-2025.csv')); const annTot = {}; annTotRaw.forEach((r) => (annTot[+r.year] = +r.kc_me_overdose_deaths)); const annSit = {}; annRaw.forEach((r) => ((annSit[+r.year] ||= {})[r.living_situation] = +r.deaths)); const t2 = []; for (let y = 2019; y <= 2025; y++) { const named = SITS.filter((s) => s !== 'Unknown').reduce((a, s) => a + annSit[y][s], 0); for (const s of SITS) { let v = annSit[y][s]; let vt = 'observed'; if (s === 'Unknown' && v === undefined) { v = annTot[y] - named; vt = 'derived'; } t2.push({ year: y, living_situation: s, deaths: v, value_type: vt, county_total: annTot[y] }); } } // ---------- Table 3: by region (place of death), YTD and full year ---------- const annReg = readCsv(path.join(IN, 'kc-me-deaths-by-region-2016-2025.csv')); const t3 = []; for (let y = 2016; y <= 2026; y++) for (const reg of ['Seattle', 'South', 'East', 'North']) { const full = annReg.find((r) => +r.year === y && r.region === reg); t3.push({ year: y, region: reg, deaths_jan1_nov5: ytdRegion[y][reg], deaths_full_year: full ? +full.deaths : null }); } // ---------- Table 4: HUD Housing Inventory Count beds, WA-500 ---------- const hic = readCsv(path.join(ROOT, 'data', 'hic', 'hic_panel.csv')).filter((r) => r.coc_code === 'WA-500' && +r.year >= 2019); const t4 = hic.map((r) => ({ year: +r.year, psh_beds: +r.psh_beds, oph_beds: +r.oph_beds, psh_plus_oph_beds: +r.psh_beds + +r.oph_beds, es_beds: +r.es_beds })); // ---------- Table 5: CDC provisional counts, 12 months ending December ---------- const cdc = readCsv(path.join(IN, 'cdc-vsrr-us-wa-12mo-ending-dec-2015-2026.csv')).filter((r) => r.month === 'December'); const cdcK = readCsv(path.join(IN, 'cdc-vsrr-county-12mo-ending-peers.csv')).filter((r) => r.countyname === 'King' && r.month === '12'); const t5 = [ ...cdc.map((r) => ({ geography: r.state === 'US' ? 'United States' : 'Washington', year: +r.year, deaths: +r.data_value, basis: 'reported, place of residence' })), ...cdcK.map((r) => ({ geography: 'King County (residents)', year: +r.year, deaths: +r.provisional_drug_overdose, basis: 'provisional, place of residence' })), ]; // ---------- write CSVs ---------- const files = { 'ytd-by-living-situation.csv': toCsv(t1, ['year', 'period', 'living_situation', 'deaths', 'value_type', 'county_total']), 'annual-by-living-situation.csv': toCsv(t2, ['year', 'living_situation', 'deaths', 'value_type', 'county_total']), 'deaths-by-region.csv': toCsv(t3, ['year', 'region', 'deaths_jan1_nov5', 'deaths_full_year']), 'hic-beds-king-county.csv': toCsv(t4, ['year', 'psh_beds', 'oph_beds', 'psh_plus_oph_beds', 'es_beds']), 'cdc-annual-overdose-deaths.csv': toCsv(t5, ['geography', 'year', 'deaths', 'basis']), }; for (const [f, s] of Object.entries(files)) fs.writeFileSync(path.join(OUT, f), s); fs.copyFileSync(__filename, path.join(OUT, 'build_housing_od.js')); // ---------- analyses ---------- const get1 = (y, s) => t1.find((r) => r.year === y && r.living_situation === s).deaths; const get2 = (y, s) => t2.find((r) => r.year === y && r.living_situation === s).deaths; const H = 'Housing service site'; const a1rows = YEARS.map((y) => { const known = ytdTotal[y] - get1(y, 'Unknown'); return { year: String(y), share: round(get1(y, H) / known, 4), housing_deaths: get1(y, H), deaths_with_known_place: known, unknown_place: get1(y, 'Unknown'), share_of_all_deaths: round(get1(y, H) / ytdTotal[y], 4), share_if_all_unknown_were_housing: round((get1(y, H) + get1(y, 'Unknown')) / ytdTotal[y], 4) }; }); const labels = { [H]: 'Subsidized and supportive housing', 'Privately owned or rented residence': 'Private homes', 'Outdoors, homeless shelter, or vehicle': 'Outdoors, shelters, vehicles', 'Other temporary arrangement': 'Other temporary stays', }; const a2rows = [...Object.keys(labels).map((s) => ({ place: labels[s], deaths_2025: get1(2025, s), deaths_2026: get1(2026, s), change: round(get1(2026, s) / get1(2025, s) - 1, 4) })), { place: 'All overdose deaths', deaths_2025: ytdTotal[2025], deaths_2026: ytdTotal[2026], change: round(ytdTotal[2026] / ytdTotal[2025] - 1, 4) }]; // sensitivity: unknowns in 2026 beyond 2025's count assigned entirely to one group const extraUnknown = get1(2026, 'Unknown') - get1(2025, 'Unknown'); const a3rows = Object.keys(labels).map((s) => ({ place: labels[s], change_if_all_extra_unknown_assigned_here: round((get1(2026, s) + extraUnknown) / get1(2025, s) - 1, 4), change_as_reported: round(get1(2026, s) / get1(2025, s) - 1, 4) })); const a4rows = [...Object.keys(labels).map((s) => ({ place: labels[s], deaths_2019: get2(2019, s), deaths_2025: get2(2025, s), multiple: round(get2(2025, s) / get2(2019, s), 2) })), { place: 'All overdose deaths', deaths_2019: annTot[2019], deaths_2025: annTot[2025], multiple: round(annTot[2025] / annTot[2019], 2) }]; const beds = (y) => t4.find((r) => r.year === y); const a5rows = [2019, 2025].map((y) => ({ year: y, housing_deaths: get2(y, H), psh_plus_oph_beds: beds(y).psh_plus_oph_beds, deaths_per_1000_beds: round(get2(y, H) / beds(y).psh_plus_oph_beds * 1000, 1) })); const a6rows = [ ...Object.keys(labels).map((s) => ({ group: labels[s], deaths_2023: get1(2023, s), deaths_2026: get1(2026, s), change: round(get1(2026, s) / get1(2023, s) - 1, 4) })), ...['Seattle', 'South', 'East', 'North'].map((g) => ({ group: 'Region: ' + g, deaths_2023: ytdRegion[2023][g], deaths_2026: ytdRegion[2026][g], change: round(ytdRegion[2026][g] / ytdRegion[2023][g] - 1, 4) })), ]; const a6b = ['Seattle', 'South', 'East', 'North'].map((g) => ({ region: g, deaths_2025: ytdRegion[2025][g], deaths_2026: ytdRegion[2026][g], change: round(ytdRegion[2026][g] / ytdRegion[2025][g] - 1, 4) })); const cdcv = (g, y) => t5.find((r) => r.geography === g && r.year === y).deaths; const a7rows = [ { geography: 'United States', deaths_2019: cdcv('United States', 2019), deaths_2025: cdcv('United States', 2025), ratio: round(cdcv('United States', 2025) / cdcv('United States', 2019), 2), source: 'CDC, place of residence' }, { geography: 'Washington', deaths_2019: cdcv('Washington', 2019), deaths_2025: cdcv('Washington', 2025), ratio: round(cdcv('Washington', 2025) / cdcv('Washington', 2019), 2), source: 'CDC, place of residence' }, { geography: 'King County', deaths_2019: annTot[2019], deaths_2025: annTot[2025], ratio: round(annTot[2025] / annTot[2019], 2), source: 'King County Medical Examiner, place of death' }, ]; fs.writeFileSync(path.join(OUT, 'analyses.json'), JSON.stringify({ a1rows, a2rows, a3rows, a4rows, a5rows, a6rows, a6b, a7rows, extraUnknown }, null, 2)); console.log(JSON.stringify({ a1rows, a2rows, a3rows, a4rows, a5rows, a6rows, a6b, a7rows, extraUnknown }, null, 1)); for (const f of Object.keys(files)) { const b = fs.readFileSync(path.join(OUT, f)); console.log(f, b.length, 'sha256:' + crypto.createHash('sha256').update(b).digest('hex')); }