Files
crdc-demo/src/hooks/useApi.js
T
jared f65a70fc3e
Deploy to git-pages / deploy (push) Successful in 19s
Add D3 and new ridgeline chart matching R aesthetic
- 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)
2026-08-10 16:15:33 -04:00

149 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}` : ''}`)
}