/** * 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=&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}` : ''}`) }