Deploy to git-pages / deploy (push) Successful in 19s
- Install d3@7 - Add fetchDistrictDraws API function (for future use with raw draws) - Create RateDensityRidgeline component using D3.js - Density ridges showing posterior distributions per race group - Diamond markers for observed rates (matching R design) - Dropdown to switch between model specifications - Matches Civilytics color palette (navy fill, danger diamonds)
149 lines
6.0 KiB
JavaScript
149 lines
6.0 KiB
JavaScript
/**
|
||
* Civilytics CRDC Arrest Rate API client.
|
||
* Wraps fetch() with retry, backoff, and typed error handling.
|
||
* Base URL: https://crdc-api.civilytics.org/api/v1
|
||
*/
|
||
|
||
// The git-pages static host serves from pages.civilytics.org/crdc-demo/ but the CRDC API
|
||
// at crdc-api.civilytics.org does not send CORS headers. To work around this, we proxy
|
||
// requests through a simple serverless function deployed alongside the app.
|
||
// If no proxy is available (VITE_PROXY_URL unset), fall back to direct fetch — works when
|
||
// served from same origin or via Docker with nginx reverse proxy.
|
||
|
||
const BASE_URL = import.meta.env.VITE_API_BASE || 'https://crdc-api.civilytics.org/api/v1'
|
||
const PROXY_URL = import.meta.env.VITE_PROXY_URL // e.g., https://pages.civilytics.org/crdc-demo/proxy/
|
||
|
||
// Student group labels matching the API enum (race=AM|BL|HI|WH; sex=F|M)
|
||
export const STUDENT_GROUPS = [
|
||
{ race: 'WH', sex: 'F', label: 'White Female' },
|
||
{ race: 'WH', sex: 'M', label: 'White Male' },
|
||
{ race: 'BL', sex: 'F', label: 'Black Female' },
|
||
{ race: 'BL', sex: 'M', label: 'Black Male' },
|
||
{ race: 'HI', sex: 'F', label: 'Hispanic Female' },
|
||
{ race: 'HI', sex: 'M', label: 'Hispanic Male' },
|
||
{ race: 'AM', sex: 'F', label: 'American Indian\nFemale' },
|
||
{ race: 'AM', sex: 'M', label: 'American Indian\nMale' },
|
||
]
|
||
|
||
// CRDC wave labels (year=15-16|17-18|21-22)
|
||
export const CRDC_WAVES = [
|
||
{ value: '15-16', label: '2015–16' },
|
||
{ value: '17-18', label: '2017–18' },
|
||
{ value: '21-22', label: '2021–22' },
|
||
]
|
||
|
||
// Model IDs returned by /models endpoint
|
||
export const MODEL_IDS = [
|
||
'unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod', 'unified_m5_mod',
|
||
'stratified_m1_mod', 'stratified_m2_mod', 'stratified_m3_mod', 'stratified_m4_mod', 'stratified_m5_mod',
|
||
]
|
||
|
||
// The "four quadrants" for distribution charts (Charts 4–6)
|
||
export const MODEL_QUADRANTS = [
|
||
{ model: 'unified_m1_mod', label: 'One-year, no covariate' },
|
||
{ model: 'unified_m2_mod', label: 'One-year + referral rate' },
|
||
{ model: 'unified_m3_mod', label: 'Three-year, no covariate' },
|
||
{ model: 'unified_m4_mod', label: 'Three-year + referral rate' },
|
||
]
|
||
|
||
// Default/recommended model (from validate.R)
|
||
export const DEFAULT_MODEL = 'unified_m2_mod'
|
||
|
||
/** Exponential backoff fetch wrapper */
|
||
async function apiFetch(path, { retries = 3, delay = 500 } = {}) {
|
||
// If a proxy URL is configured, route through it to bypass CORS restrictions.
|
||
// The proxy simply forwards the request and adds Access-Control-Allow-Origin: *.
|
||
const url = PROXY_URL ? `${PROXY_URL}?target=${encodeURIComponent(path)}` : `${BASE_URL}${path}`
|
||
|
||
let lastError
|
||
|
||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||
try {
|
||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||
|
||
/** @type {{status:string,data:any,error?:string,meta:object}} */
|
||
const envelope = PROXY_URL ? await res.json() : (await res.json())
|
||
// When using a proxy that returns the raw API response, unwrap it.
|
||
if (PROXY_URL && envelope.status) return envelope.data
|
||
if (!PROXY_URL && envelope.status === 'success') return envelope.data
|
||
|
||
throw new Error(envelope.error || 'Unknown API error')
|
||
} catch (err) {
|
||
lastError = err
|
||
if (attempt < retries && !(err instanceof DOMException)) {
|
||
const wait = delay * Math.pow(2, attempt) + Math.random() * 100
|
||
await new Promise((r) => setTimeout(r, wait))
|
||
} else {
|
||
throw lastError
|
||
}
|
||
}
|
||
}
|
||
|
||
throw lastError
|
||
}
|
||
|
||
// ——— API endpoint wrappers (return the `data` array from the envelope) ———
|
||
|
||
/** GET /models — static list of available Bayesian model specifications */
|
||
export async function fetchModels() {
|
||
return apiFetch('/models')
|
||
}
|
||
|
||
/** GET /districts?q=<partial>&state=XX — name/geo lookup → LEAID */
|
||
export async function searchDistricts(query, state) {
|
||
const params = new URLSearchParams({ q: query, state })
|
||
return apiFetch(`/districts?${params}`)
|
||
}
|
||
|
||
/** GET /estimates/{leaid}?model=X&year=Y — all demographics for one district/year/model */
|
||
export async function fetchDistrictEstimates(leaid, options = {}) {
|
||
const params = new URLSearchParams()
|
||
if (options.model) params.set('model', options.model)
|
||
if (options.year) params.set('year', options.year)
|
||
if (options.race) params.set('race', options.race)
|
||
if (options.sex) params.set('sex', options.sex)
|
||
|
||
const qs = params.toString()
|
||
return apiFetch(`/estimates/${leaid}${qs ? `?${qs}` : ''}`)
|
||
}
|
||
|
||
/** GET /states/{state}?race=X&sex=Y&year=Z — state-level aggregate for one group */
|
||
export async function fetchStateEstimates(state, options = {}) {
|
||
const params = new URLSearchParams()
|
||
if (options.race) params.set('race', options.race)
|
||
if (options.sex) params.set('sex', options.sex)
|
||
if (options.year) params.set('year', options.year)
|
||
|
||
const qs = params.toString()
|
||
return apiFetch(`/states/${state}${qs ? `?${qs}` : ''}`)
|
||
}
|
||
|
||
/** GET /estimates?state=XX&year=Y — all districts in a state for "interesting" suggestions */
|
||
export async function fetchStateDistricts(state, year = '21-22', limit = 500) {
|
||
return apiFetch(`/estimates?state=${state}&year=${year}&limit=${limit}`)
|
||
}
|
||
|
||
/** GET /draws?... — returns HF parquet shard URL + DuckDB SQL (bulk only, not used in browser app) */
|
||
export async function fetchDrawLocation(state, race, sex, year, model) {
|
||
const params = new URLSearchParams({ state })
|
||
if (race) params.set('race', race)
|
||
if (sex) params.set('sex', sex)
|
||
if (year) params.set('year', year)
|
||
if (model) params.set('model', model)
|
||
|
||
return apiFetch(`/draws?${params}`)
|
||
}
|
||
|
||
/** GET /estimates/{leaid}/draws?model=X&year=Y — fetch posterior draws for a district */
|
||
export async function fetchDistrictDraws(leaid, options = {}) {
|
||
const params = new URLSearchParams()
|
||
if (options.model) params.set('model', options.model)
|
||
if (options.year) params.set('year', options.year)
|
||
if (options.race) params.set('race', options.race)
|
||
if (options.sex) params.set('sex', options.sex)
|
||
|
||
const qs = params.toString()
|
||
return apiFetch(`/estimates/${leaid}/draws${qs ? `?${qs}` : ''}`)
|
||
}
|