// node build_tables.js -> dataset/*.csv const fs=require('fs'),path=require('path');const R=process.argv[2];const D=path.join(R,'data'),O=path.join(R,'dataset'); function csv(t){const rows=[];let r=[],f='',q=false;t=t.replace(/^/,'');for(let i=0;ix.length>1).map(x=>Object.fromEntries(h.map((k,i)=>[k,x[i]])))} const q=s=>{s=String(s??'');return /[",\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s}; const w=(f,h,rows)=>fs.writeFileSync(path.join(O,f),h.join(',')+'\n'+rows.map(r=>h.map(k=>q(r[k])).join(',')).join('\n')+'\n'); const norm=a=>(a||'').toUpperCase().replace(/\s+/g,' ').trim(); const yrs=['2023','2024','2025','2026janoct']; const rows=JSON.parse(fs.readFileSync(path.join(D,'od_addr_combined.json'))); const psh=csv(fs.readFileSync(path.join(D,'psh_buildings.csv'),'utf8')); const P={};psh.filter(b=>/^PSH/.test(b.building_type)&&!/former/i.test(b.name)&&/^\d/.test(b.address)).forEach(b=>{const a=norm(b.address);P[a]=P[a]||{names:[],ops:new Set(),units:0,src:b.source_url};P[a].names.push(b.name);P[a].ops.add(b.operator);P[a].units+=(+b.psh_units||+b.total_units||0)}); const ids=csv(fs.readFileSync(path.join(D,'top_address_ids.csv'),'utf8'));const ID={};ids.forEach(r=>ID[norm(r.address)]=r); const cat=t=>{t=(t||'').toLowerCase();if(/^psh/.test(t))return 'Supportive housing';if(/hth emergency/.test(t))return 'County emergency housing';if(/jail/.test(t))return 'Jail and shelter';if(/shelter/.test(t))return 'Shelter';if(/mixed market/.test(t))return 'Mixed-income apartments';if(/public housing|affordable|low-income|subsidized|apartment/.test(t))return t.includes('student')||t.includes('+ store')?'Other apartments':'Low-income or affordable apartments';if(/store/.test(t))return 'Store or gas station';if(/park/.test(t))return 'Park';if(/transit|library|office|day center|other/.test(t))return 'Other public place';return 'Not identified'}; const bm={};rows.forEach(r=>{const a=norm(r.a);if(a.includes('/'))return;bm[a]=bm[a]||{a,t:0,v:{}};bm[a].t+=r.t;yrs.forEach(y=>bm[a].v[y]=(bm[a].v[y]||0)+(r.v[y]||0))}); const ball=Object.values(bm).sort((x,y)=>y.t-x.t||x.a.localeCompare(y.a)); const top=ball.slice(0,50).map((b,i)=>{const id=ID[b.a]||{};let type=P[b.a]?'Supportive housing':cat(id.type);if(b.a==='4732 BROOKLYN AVE NE')type='Other apartments';let place=P[b.a]?P[b.a].names.join(' + '):(id.building_name||'');let op=P[b.a]?[...P[b.a].ops].join(' / '):(id.operator||'');if(op.startsWith("("))op='';if(b.a==='216 JAMES ST'){place='Possibly a side address of the Lyon Building (not confirmed)';type='Not confirmed';op='';}if(b.a==='105 5TH AVE S'){place='Fire Station 10 parcel (city property)';}return {rank:ball.findIndex(x=>x.t===b.t)+1,address:b.a,place:place||'Not identified',place_type:type,operator:op,units:P[b.a]?P[b.a].units:(b.a==='216 JAMES ST'?'':(id.units||'')),calls_2023:b.v['2023'],calls_2024:b.v['2024'],calls_2025:b.v['2025'],calls_2026_jan_oct:b.v['2026janoct'],calls_total:b.t,source_url:P[b.a]?P[b.a].src:(id.source_url||'')}}); w('top-building-addresses.csv',Object.keys(top[0]),top); // PSH buildings const rr=JSON.parse(fs.readFileSync(path.join(D,'rrio_with_calls.json')));const RU={};rr.forEach(b=>RU[b.a]=b.units); const yrsN=3+10/12; const prow=Object.entries(P).map(([a,p])=>{const v=bm[a]?bm[a].v:{};const t=bm[a]?bm[a].t:0;return {building:p.names.join(' + '),address:a,operator:[...p.ops].join(' / '),psh_units:p.units,calls_2023:v['2023']||0,calls_2024:v['2024']||0,calls_2025:v['2025']||0,calls_2026_jan_oct:v['2026janoct']||0,calls_total:t,calls_per_100_units_per_year:p.units?(t/p.units*100/yrsN).toFixed(2):'',rank_among_street_addresses:t?ball.findIndex(x=>x.t===t)+1:'',source_url:p.src}}).sort((x,y)=>y.calls_total-x.calls_total); w('supportive-housing-buildings.csv',Object.keys(prow[0]),prow); // comparison const pT=prow.reduce((s,r)=>s+r.calls_total,0),pU=prow.reduce((s,r)=>s+r.psh_units,0); const nonP=rr.filter(b=>b.units>=50&&!P[b.a]&&!['14115 AURORA AVE N','13300 STONE AVE N'].includes(b.a));const nT=nonP.reduce((s,b)=>s+b.t,0),nU=nonP.reduce((s,b)=>s+b.units,0); const comp=[{group:'Supportive housing buildings',buildings:prow.length,units:pU,overdose_calls:pT,calls_per_100_units_per_year:+(pT/pU*100/yrsN).toFixed(2),share_of_buildings_with_no_calls:+(prow.filter(r=>!r.calls_total).length/prow.length).toFixed(3)},{group:'Other registered rental buildings with 50+ units',buildings:nonP.length,units:nU,overdose_calls:nT,calls_per_100_units_per_year:+(nT/nU*100/yrsN).toFixed(2),share_of_buildings_with_no_calls:+(nonP.filter(b=>!b.t).length/nonP.length).toFixed(3)}]; w('per-unit-comparison.csv',Object.keys(comp[0]),comp); fs.writeFileSync(path.join(O,'comparison-buildings.csv'),'address,registered_units,overdose_calls_total\n'+nonP.sort((x,y)=>y.t-x.t||x.a.localeCompare(y.a)).map(b=>[q(b.a),b.units,b.t].join(',')).join('\n')+'\n'); // yearly const sfd={'2023':6538,'2024':5203,'2025':5001}; const yr=yrs.map(y=>{const all=rows.reduce((s,r)=>s+(r.v[y]||0),0),inter=rows.filter(r=>r.a.includes('/')).reduce((s,r)=>s+(r.v[y]||0),0),p=prow.reduce((s,r)=>s+r['calls_'+(y=='2026janoct'?'2026_jan_oct':y)],0);return {period:y=='2026janoct'?'2026 (Jan-Oct)':y,overdose_calls_all:all,at_intersections:inter,at_street_addresses:all-inter,at_supportive_housing:p,supportive_housing_share_of_all:+(p/all).toFixed(3),sfd_suspected_overdose_responses:sfd[y]??'',overdose_code_share_of_sfd_responses:sfd[y]?+(all/sfd[y]).toFixed(3):''}}); w('yearly-totals.csv',Object.keys(yr[0]),yr); // coding check const f=n=>{const d=JSON.parse(fs.readFileSync(path.join(D,n)));let med=0,od=0;d.forEach(r=>{const k=+r.n;if(/aid|medic/i.test(r.type)&&!/mutual/i.test(r.type))med+=k;if(/overdose/i.test(r.type))od+=k});return {med,od}}; const pc=f('psh_alltypes_2023_2026oct.json'),cc=f('city_alltypes_2023_2026oct.json'); const code=[{group:'All of Seattle',medical_calls:cc.med,overdose_calls:cc.od,overdose_share_of_medical:+(cc.od/cc.med).toFixed(4)},{group:'Supportive housing addresses',medical_calls:pc.med,overdose_calls:pc.od,overdose_share_of_medical:+(pc.od/pc.med).toFixed(4)}]; w('coding-check.csv',Object.keys(code[0]),code); console.log(JSON.stringify({comp,yr,code,top10:top.slice(0,12).map(t=>[t.rank,t.place,t.place_type,t.calls_total])},null,1));