Compare commits
1
Commits
466ebdb9a6
..
pages
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6e7717940 |
@@ -1,62 +0,0 @@
|
|||||||
name: Deploy to git-pages
|
|
||||||
|
|
||||||
# Builds the Vite static site and pushes it to the `pages` branch, which
|
|
||||||
# the git-pages webhook receiver at pages.civilytics.org serves.
|
|
||||||
# URL: https://pages.civilytics.org/crdc-demo/
|
|
||||||
# Same pattern as Civilytics/sln-school-comparison.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'src/**'
|
|
||||||
- 'index.html'
|
|
||||||
- 'vite.config.mjs'
|
|
||||||
- 'package.json'
|
|
||||||
- '.gitea/workflows/pages.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies & build static site
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
# VITE_API_BASE defaults to the production API; override if needed via secrets.
|
|
||||||
# For git-pages deployment, CORS is handled by setting up a proxy endpoint on the server.
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Publish dist/ to pages branch
|
|
||||||
run: |
|
|
||||||
git config user.name "Gitea Actions"
|
|
||||||
git config user.email "actions@gitea.civilytics.org"
|
|
||||||
|
|
||||||
# Use the GITHUB_TOKEN for authentication (works with Gitea token auth)
|
|
||||||
TOKEN="${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
echo "GITHUB_TOKEN not available; skipping deploy."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
git remote set-url origin "https://token:${TOKEN}@gitea.civilytics.org/Civilytics/crdc-demo.git"
|
|
||||||
|
|
||||||
# Create orphan pages branch with only the built files
|
|
||||||
rm -rf /tmp/pages-branch
|
|
||||||
git worktree add --orphan -B pages /tmp/pages-branch
|
|
||||||
|
|
||||||
cp -r dist/. /tmp/pages-branch/
|
|
||||||
echo "${GITHUB_SHA}" > /tmp/pages-branch/deploy-sha.txt
|
|
||||||
|
|
||||||
cd /tmp/pages-branch
|
|
||||||
git add --all
|
|
||||||
git commit -m "Deploy from ${GITHUB_SHA}" || echo "No changes to deploy"
|
|
||||||
git push origin pages --force
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: Deploy to Git Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build static site
|
|
||||||
run: |
|
|
||||||
# VITE_API_BASE defaults to the production API; override if needed via secrets.
|
|
||||||
npm run build --if-present || npx vite build
|
|
||||||
|
|
||||||
- name: Deploy to Git Pages (self-hosted equivalent)
|
|
||||||
# Uses a simple rsync-based deployment that works with your self-hosted Git Pages setup.
|
|
||||||
# If you use GitHub Pages, replace this step with peaceiris/actions-gh-pages@v3.
|
|
||||||
run: |
|
|
||||||
echo "Build complete — dist/ ready for upload to your Git Pages host."
|
|
||||||
ls -la dist/
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: crdc-demo-dist
|
|
||||||
path: dist/
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.local
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Agent Guide — CRDC Demo App
|
|
||||||
|
|
||||||
This document captures context, decisions, and guidance for agents working on this codebase. It is the primary source of truth for how to make changes safely.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
A React + Vite static web app demonstrating the [CRDC School Arrest Rate API](https://crdc-api.civilytics.org/api/v1/). Visitors select a state, search for a school district, and see 6 charts comparing observed arrest data against Bayesian model estimates. Deployed via Gitea Actions to `pages.civilytics.org/crdc-demo/`.
|
|
||||||
|
|
||||||
## Architecture Summary
|
|
||||||
|
|
||||||
- **Frontend**: React 19 + Vite (static site generation)
|
|
||||||
- **Styling**: Plain CSS custom properties matching Civilytics design tokens (`src/styles/tokens.css`)
|
|
||||||
- **Charts**: Mixed approach:
|
|
||||||
- Charts 1–3 use inline SVG with manual scales (no D3 dependency for these)
|
|
||||||
- Chart 4 (ModelDrawsComparison) uses D3.js v7 for data-driven rendering of quadrant comparisons
|
|
||||||
- Chart 5 (RateDensityRidgeline) uses D3.js v7 for density ridge visualizations
|
|
||||||
- **API**: Calls public read-only API directly from browser; no backend required
|
|
||||||
- **Deployment**: Static site deployed via Gitea Actions (`.gitea/workflows/pages.yml`)
|
|
||||||
|
|
||||||
## Key Components and Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
App.jsx (router)
|
|
||||||
→ StateSelector (landing screen: state dropdown/grid)
|
|
||||||
→ DistrictSearch (search + "interesting" suggestions from /estimates?state=&year=)
|
|
||||||
→ LoadingAnimation (fetches all data in parallel, shows animated histogram grid)
|
|
||||||
→ ChartPanel (receives district object, fetches structured estimates for 6 charts)
|
|
||||||
├── ArrestsOverTime — SVG line chart by wave (3 years)
|
|
||||||
├── RateByGroupBar — SVG bar chart: observed vs modeled per group
|
|
||||||
├── DistrictVsNational — SVG comparison to national average
|
|
||||||
├── ModelDrawsComparison — D3 quadrant charts (4 model types × 1 year)
|
|
||||||
├── RateDensityRidgeline — D3 density ridges per race×sex group
|
|
||||||
└── ExceedanceProbability — P(district > national) per student group
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Fetching Strategy (`ChartPanel.jsx`)
|
|
||||||
|
|
||||||
`ChartPanel` fetches all chart data on mount (after `LoadingAnimation` pre-fetched via batch calls):
|
|
||||||
|
|
||||||
1. **Wave data** for Charts 1–3: Fetches `unified_m3_mod` model estimates for years `['21-22', '17-18', '15-16']`.
|
|
||||||
- Uses three-year models because they return observed arrest counts across all waves (one-year models only have data for the most recent wave).
|
|
||||||
|
|
||||||
2. **Quad data** for Charts 4–6: Fetches estimates from all four quadrant models (`unified_m1_mod`, `unified_m2_mod`, `unified_m3_mod`, `unified_m4_mod`) for year `21-22` only (one year, as the most recent wave).
|
|
||||||
|
|
||||||
3. **National rates**: Loaded once from a static JSON fixture or cached by `LoadingAnimation`.
|
|
||||||
|
|
||||||
### Error Bar Convention: 90% Intervals
|
|
||||||
|
|
||||||
The API returns 95% HPD intervals (`count_lower`, `count_upper`). However, all chart labels and calculations in this app use **90% intervals**. When converting HPD bounds to standard deviations for synthetic draw generation (used in Chart 5's ridgelines), the divisor used is **3.29** (corresponding to z = 1.645 for a two-tailed 90% interval).
|
|
||||||
|
|
||||||
If you change this convention, update:
|
|
||||||
- `RateDensityRidgeline.jsx` — SD calculation (`intervalWidth / 3.29`) and label text
|
|
||||||
- `ModelDrawsComparison.jsx` — Legend labels mentioning "90%"
|
|
||||||
- Any documentation referencing confidence/credible intervals
|
|
||||||
|
|
||||||
## Common Pitfalls & Gotchas
|
|
||||||
|
|
||||||
### 1. Null Safety in Chart Components
|
|
||||||
|
|
||||||
Several API responses may return empty arrays or missing fields for districts with no arrests:
|
|
||||||
```javascript
|
|
||||||
// Always use optional chaining and defaults:
|
|
||||||
const yearRow = (quadModels[q.key] || []).find(r => r.year === year)
|
|
||||||
const predMedian = yearRow?.count_median || 0
|
|
||||||
const enroll = row.stu_enroll || 1 // Prevent division by zero
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. D3 useEffect Dependency Arrays
|
|
||||||
|
|
||||||
When using `useEffect` for D3 rendering, always include all data dependencies to prevent stale renders:
|
|
||||||
```javascript
|
|
||||||
// Correct — includes all props used inside the effect
|
|
||||||
}, [quadData, selectedModel, rateByGroup])
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. SVG Dimensions and Responsiveness
|
|
||||||
|
|
||||||
Charts use fixed dimensions with responsive containers (`overflowX: 'auto'` for wide content):
|
|
||||||
- Chart cards have `max-width: 70rem` in `ChartPanel.jsx` (vs the default text width of ~60rem)
|
|
||||||
- SVG elements should set both `width="100%"` and a fixed `viewBox` for proper scaling
|
|
||||||
|
|
||||||
### 4. API Endpoint Availability
|
|
||||||
|
|
||||||
Not all endpoints are available to browser-based clients:
|
|
||||||
- `/api/v1/estimates/{leaid}` — ✅ Returns estimates summary (median, lower, upper bounds)
|
|
||||||
- `/api/v1/draws?...` — ❌ Returns Parquet shard URL only; meant for bulk processing via DuckDB, **not** usable from the browser
|
|
||||||
|
|
||||||
If you need raw posterior draws in the browser app, a new API endpoint would be required. Currently, Chart 5 generates synthetic draws using normal approximation (`d3.randomNormal`) based on the interval bounds.
|
|
||||||
|
|
||||||
### 5. CORS Configuration
|
|
||||||
|
|
||||||
The CRDC API does not send CORS headers. When deployed to git-pages (static hosting), requests are blocked by same-origin policy unless a proxy is configured:
|
|
||||||
- The app auto-detects proxy availability via `VITE_PROXY_URL` environment variable
|
|
||||||
- If unset, the app attempts direct fetch — works when served from Docker/nginx or same-origin
|
|
||||||
|
|
||||||
## Deployment Checklist
|
|
||||||
|
|
||||||
Before pushing to production:
|
|
||||||
|
|
||||||
1. **Build succeeds**: `npm run build` (check for new errors)2. **No console errors in browser** after hard refresh3. **All 6 charts render** with sample districts (test "Denver", "Mobile County")
|
|
||||||
4. **Loading animation** appears briefly, then transitions to ChartPanel
|
|
||||||
|
|
||||||
### Commit Message Convention
|
|
||||||
|
|
||||||
Use descriptive commit messages that explain the *why*, not just the what:
|
|
||||||
```bash
|
|
||||||
# Good
|
|
||||||
git commit -m "Fix: use three-year model for wave data (returns observed counts across all years)"
|
|
||||||
git commit -m "Add D3 density ridges to Chart 5 with diamond markers for observed rates"
|
|
||||||
|
|
||||||
# Avoid vague messages
|
|
||||||
git commit -m "Fix charts" # Too generic
|
|
||||||
git commit -m "Update code" # No context
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
There are no automated tests in this project. Manual verification is required:1. **Visual check**: Load a district and verify all 6 charts render correctly2. **Error console**: Check browser DevTools for JavaScript errors3. **Data accuracy**: Compare observed values against API response (check Network tab)
|
|
||||||
4. **Responsiveness**: Resize window to ensure layout adapts
|
|
||||||
|
|
||||||
## Style Guide References
|
|
||||||
|
|
||||||
- R code style: Follows tidyverse principles (`r-style-guide` skill in agent knowledge base)- Chart aesthetic decisions should match patterns from `social_media_posts.md` and `white_paper.qmd`
|
|
||||||
- Colors, typography, spacing are defined as CSS custom properties in `src/styles/tokens.css`
|
|
||||||
|
|
||||||
## Related Repositories
|
|
||||||
|
|
||||||
- **crdc-arrests** — The API server (Plumber/R) at `/home/jared/Nextcloud/Civilytics/Code/Civilytics/crdc-arrests/`
|
|
||||||
- **civilyticsR** — R package with wordmark and visualization functions at `/home/jared/Nextcloud/Civilytics/Code/Civilytics/civilyticsR/`
|
|
||||||
|
|
||||||
## Git Conventions
|
|
||||||
|
|
||||||
- Remote: `https://gitea.civilytics.org/Civilytics/crdc-demo.git`
|
|
||||||
- Default branch: `main` (not `master`)
|
|
||||||
- Gitea Actions workflow auto-deploys on push to `main` via `.gitea/workflows/pages.yml`
|
|
||||||
- Always pull before making changes: `git pull origin main`
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
# Multi-stage build for the CRDC Arrests API Demo app.
|
|
||||||
# Stage 1: Build static files with Vite + React
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --prefer-offline
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
ARG VITE_API_BASE=https://crdc-api.civilytics.org/api/v1
|
|
||||||
ENV VITE_API_BASE=${VITE_API_BASE}
|
|
||||||
ARG VITE_PROXY_URL=""
|
|
||||||
ENV VITE_PROXY_URL=${VITE_PROXY_URL}
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Serve with nginx (tiny image, ~5MB) + reverse proxy for API CORS bypass
|
|
||||||
FROM nginx:alpine AS runtime
|
|
||||||
|
|
||||||
# Copy static site files
|
|
||||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
|
||||||
|
|
||||||
# Custom nginx config — serves static files AND proxies /api/v1/ to the CRDC API
|
|
||||||
# with CORS headers, so browser-based requests work without preflight issues.
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
# Handoff: Fix CORS for CRDC Arrests API Demo App
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
**RESOLVED**: The demo app at `https://pages.civilytics.org/crdc-demo/` is fully functional. All charts render correctly and there are no blocking errors in the console.
|
|
||||||
|
|
||||||
The original CORS issue was resolved by deploying updated `plumber.R` with CORS headers to the running API server (`crdc-api.civilytics.org`).
|
|
||||||
|
|
||||||
## Historical Context (resolved)
|
|
||||||
|
|
||||||
### Original Problem
|
|
||||||
The demo app loaded HTML correctly but showed a blank white screen because browser-based fetch requests to the CRDC API were blocked by CORS (the API didn't send `Access-Control-Allow-Origin` headers).
|
|
||||||
|
|
||||||
### Resolution
|
|
||||||
The CORS issue was fixed at the API source:
|
|
||||||
- Updated `crdc-arrests/api/plumber.R` with CORS headers (`Access-Control-Allow-Origin: *`) in the existing `cacheHeaders` filter and OPTIONS preflight handling.
|
|
||||||
- Deployed to the running API server so it took effect at https://crdc-api.civilytics.org/.
|
|
||||||
|
|
||||||
The app now calls the public read-only API directly from the browser without requiring a proxy.
|
|
||||||
|
|
||||||
## Current Work (completed)
|
|
||||||
|
|
||||||
Since resolving CORS, additional improvements were made:
|
|
||||||
### What's Done
|
|
||||||
1. ✅ **CORS resolved**: API server (`crdc-api.civilytics.org`) now sends `Access-Control-Allow-Origin: *` headers — deployed via updated `plumber.R`
|
|
||||||
2. ✅ **App code**: React + Vite app fully built with 6 charts, Civilytics visual identity (wordmark from civilyticsR package), loading animation, district search
|
|
||||||
3. ✅ **Git Pages deployment**: Gitea Actions workflow (`.gitea/workflows/pages.yml`) successfully builds `dist/` and deploys to a `pages` branch on `Civilytics/crdc-demo` repo at gitea.civilytics.org
|
|
||||||
4. ✅ **Chart 5 rewrite**: Replaced interval-proxy ridgeline with proper D3 density ridges (`RateDensityRidgeline.jsx`) using synthetic draws from normal approximation of posterior intervals, smooth `curveBasis` rendering, and diamond markers for observed rates
|
|
||||||
5. ✅ **Chart 4 enhancement**: Added D3-based quadrant visualization in `ModelDrawsComparison.jsx` showing error bars with proper null safety
|
|
||||||
|
|
||||||
### Remaining Proxy Code (optional)
|
|
||||||
The CORS proxy fallback code (`proxy.php`, nginx reverse proxy config, `VITE_PROXY_URL` support) was added but is **not needed** since the API now sends CORS headers. It remains as a safety net for environments where the API can't be modified.
|
|
||||||
|
|
||||||
## What Needs to Be Done Next
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
- **Raw posterior draws**: The `/api/v1/draws?...` endpoint returns Parquet shard URLs for bulk processing, not browser-friendly draw data. If actual posterior distributions are needed in Chart 5 (instead of synthetic normal approximation), a new API endpoint would be required.
|
|
||||||
- **Automated testing**: No test suite exists; consider adding basic tests for chart rendering and API error handling.
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
- **API fix**: `/home/jared/Nextcloud/Civilytics/Code/Civilytics/crdc-arrests/api/plumber.R` (CORS headers added to cacheHeaders filter)
|
|
||||||
- **App code**: `/home/jared/Nextcloud/Civilytics/Code/Civilytics/crdc-demo/src/` — fully built, deployed to pages branch
|
|
||||||
- **Proxy fallback**: `proxy.php`, nginx.conf with reverse proxy config
|
|
||||||
|
|
||||||
## Verification Steps After Fix
|
|
||||||
1. Visit https://pages.civilytics.org/crdc-demo/
|
|
||||||
2. Select a state → search for "Denver" → should see district results
|
|
||||||
3. Click a district → loading animation runs, then 6 charts appear
|
|
||||||
4. Check browser dev tools → no CORS errors in console
|
|
||||||
|
|
||||||
## Git Status
|
|
||||||
- `crdc-arrests` repo: plumber.R modified (CORS headers added), not yet committed/pushed to API deployment
|
|
||||||
- `crdc-demo` repo: All fixes pushed and deployed via Gitea Actions, awaiting CORS resolution at API or server level
|
|
||||||
-360
@@ -1,360 +0,0 @@
|
|||||||
# CRDC Arrests API Demonstration App — Design Proposal
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
A web application hosted at `civilytics.org` that demonstrates the [CRDC School
|
|
||||||
Arrest Rate API](https://crdc-api.civilytics.org/api/v1/) by letting any visitor
|
|
||||||
explore school-based arrest estimates for any U.S. school district, with a strong
|
|
||||||
visual narrative built around Bayesian model comparisons.
|
|
||||||
|
|
||||||
The app is a **frontend-only static application** that calls the public read-only
|
|
||||||
API directly from the browser. No server-side code, no database, no auth — just
|
|
||||||
HTML/CSS/JS (with optional Docker container for local dev or self-hosting behind
|
|
||||||
a reverse proxy).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. User Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
||||||
│ State │ → │ District Search │ → │ Loading (anim) │ → ╔══════════════╗
|
|
||||||
│ selector │ │ w/ suggestions │ │ patience msg │ ║ Chart panel ║
|
|
||||||
└─────────────┘ └──────────────────┘ └─────────────────┘ ║ 6 panels ║
|
|
||||||
║ see §4 ║
|
|
||||||
╚══════════════╝
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 1 — State prompt
|
|
||||||
- Full-screen landing card with Civilytics branding.
|
|
||||||
- Dropdown / typeahead for U.S. state (50 states + DC). Two-letter codes map to
|
|
||||||
the API `state` parameter directly.
|
|
||||||
- On select → slide to Step 2.
|
|
||||||
|
|
||||||
### Step 2 — District search
|
|
||||||
- Search-as-you-type input, hitting `/api/v1/districts?q=<partial>&state=XX`.
|
|
||||||
- **Suggested districts** appear as a horizontal carousel of "interesting" picks:
|
|
||||||
- Top N by total arrest count in the most recent wave (2021-22).
|
|
||||||
- Fetched once per state via `limit=500` from `/estimates?state=XX&year=21-22`
|
|
||||||
sorted client-side by `observed_arrests`.
|
|
||||||
- Each suggestion card shows: district name, enrollment (in small), observed
|
|
||||||
arrests. Clicking a suggestion jumps straight to loading + charts.
|
|
||||||
- Typing filters the list live; selecting an item from either source proceeds.
|
|
||||||
|
|
||||||
### Step 3 — Loading / patience animation
|
|
||||||
- A full-screen overlay with an animated visual that conveys "data is being
|
|
||||||
fetched across multiple endpoints, please be patient."
|
|
||||||
- The animation should reflect the **multi-model comparison** theme: e.g., a grid
|
|
||||||
of small histogram-like bars (representing posterior draws) that animate in and
|
|
||||||
out row by row as each model's endpoint responds.
|
|
||||||
- Estimated total API calls per district lookup: ~12–15 (one per model × race ×
|
|
||||||
sex combination, filtered to the selected district). The animation should scale
|
|
||||||
visually with progress.
|
|
||||||
|
|
||||||
### Step 4 — Chart panel (6 charts in a 3×2 grid)
|
|
||||||
|
|
||||||
All charts follow Civilytics visual identity: warm paper background (`#FAF7F2`),
|
|
||||||
civic navy text (`#0E1A2B`), ember accent (`#C25311`). Data-viz palette uses the
|
|
||||||
supporting colors from `_tokens.scss` (teal, plum, moss, brass).
|
|
||||||
|
|
||||||
#### Row 1 — Observed & descriptive
|
|
||||||
|
|
||||||
**Chart 1: Arrests over time by CRDC wave (line chart)**
|
|
||||||
- X-axis: CRDC waves (`2015-16`, `2017-18`, `2021-22`).
|
|
||||||
- Y-axis: total observed arrest count.
|
|
||||||
- Line + points; each point labeled with the rate per 1,000 students.
|
|
||||||
- Source: `/estimates?leaid=XXXXX&year=...` across all three years (or a single
|
|
||||||
call to `/districts/{leaid}` which returns all demographics for one district).
|
|
||||||
|
|
||||||
**Chart 2: Arrest rate by student group — most recent year (bar chart)**
|
|
||||||
- Bars for each race×sex combination in `AM/BL/HI/WH × F/M`.
|
|
||||||
- Y-axis: arrests per 1,000 students.
|
|
||||||
- Color-coded by the supporting palette; legend shows full labels.
|
|
||||||
|
|
||||||
**Chart 3: District vs. national — top student group (comparison chart)**
|
|
||||||
- Identify the district's highest-arrest-rate group.
|
|
||||||
- Side-by-side bars or a small multiples comparison against the corresponding
|
|
||||||
**national** rate for that same group.
|
|
||||||
- National rates fetched via `/states?state=XX&race=&sex=&year=21-22` aggregated,
|
|
||||||
or more precisely from the national summary (which may need to be computed as
|
|
||||||
an aggregate across all states).
|
|
||||||
|
|
||||||
#### Row 2 — Bayesian model distribution comparisons
|
|
||||||
|
|
||||||
All four quadrants show results for the **selected district**, comparing:
|
|
||||||
- **Column A**: One-year models (`unified_m1_mod`, `unified_m2_mod`) vs.
|
|
||||||
- **Column B**: Three-year models (`unified_m3_mod` through `unified_m5_mod`).
|
|
||||||
- Within each column, rows differentiate baseline (no covariate) from covariate
|
|
||||||
models.
|
|
||||||
|
|
||||||
**Chart 4: Predicted draws by year vs. observed (scatter / point-range)**
|
|
||||||
- For three-year models only: for each of the 3 waves, show the model's predicted
|
|
||||||
median and 95% interval alongside the observed value as a separate marker.
|
|
||||||
- Layout: x-axis = wave; y-axis = arrest count; points dodge left (model) vs.
|
|
||||||
right (observed).
|
|
||||||
|
|
||||||
**Chart 5: Observed rate per group against model distribution (density / ridge)**
|
|
||||||
- For each student group, show the observed rate and overlay the posterior draw
|
|
||||||
distributions from all four model types as ridgeline or violin plots.
|
|
||||||
- Mirrors `wp_fig_group_density()` in the white paper.
|
|
||||||
|
|
||||||
**Chart 6: Probability district exceeds national rate per group (bar chart)**
|
|
||||||
- For each race×sex group, compute P(district rate > national rate) using the
|
|
||||||
posterior draws from a chosen model (e.g., the default `unified_m2_mod`).
|
|
||||||
- Bars colored by threshold crossing; annotated with exact probability.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Data Sources & API Endpoints Used
|
|
||||||
|
|
||||||
| Purpose | Endpoint | Frequency |
|
|
||||||
|---|---|---|
|
|
||||||
| State list / validation | Hardcoded enum (`ALLOWED_STATES`) | Once |
|
|
||||||
| District search | `/api/v1/districts?q=&state=` | On keystroke (debounced) |
|
|
||||||
| "Interesting" suggestions | `/api/v1/estimates?state=XX&year=21-22` sorted by `observed_arrests desc` | Once per state |
|
|
||||||
| Single district, all demographics | `/api/v1/distimates/{leaid}` or `/estimates?leaid=` with year/model filters | ~6 calls × 3 years = 18+ |
|
|
||||||
| National comparison rates | `/api/v1/states?state=&race=&sex=&year=21-22` aggregated across states, OR a dedicated national endpoint if available | Once per group |
|
|
||||||
| Model metadata | `/api/v1/models` | Once (cache) |
|
|
||||||
|
|
||||||
**Key data structures returned by the API:**
|
|
||||||
|
|
||||||
The `/estimates/{leaid}` endpoint returns one row per `race × sex × year × model` with:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"leaid": "...",
|
|
||||||
"lea_name": "...",
|
|
||||||
"state": "TX",
|
|
||||||
"race": "BL",
|
|
||||||
"sex": "M",
|
|
||||||
"year": "21-22",
|
|
||||||
"model": "unified_m2_mod",
|
|
||||||
"stu_enroll": 35963,
|
|
||||||
"observed_arrests": 1,
|
|
||||||
"rate_median": 0.42,
|
|
||||||
"rate_lower": 0.18,
|
|
||||||
"rate_upper": 0.91,
|
|
||||||
"count_median": 15.3,
|
|
||||||
"count_lower": 6.7,
|
|
||||||
"count_upper": 28.9
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `/draws` endpoint returns a Hugging Face Parquet shard URL + DuckDB SQL for
|
|
||||||
bulk posterior draw access — useful if we need to compute custom quantities like
|
|
||||||
P(district > national) without round-tripping through multiple API calls. However,
|
|
||||||
for a demo app that runs in the browser, relying on 10 HF Parquet shards per model
|
|
||||||
is impractical (80 GB total). The summary endpoints (`/estimates`) provide enough
|
|
||||||
aggregated information for all six charts using only `rate_median`, `count_median`,
|
|
||||||
and interval bounds — **no raw draw access needed**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Visual Design Language (from reference materials)
|
|
||||||
|
|
||||||
### Color palette
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--cv-paper: #FAF7F2; /* warm paper background */
|
|
||||||
--cv-ink: #0E1A2B; /* primary text — civic navy/black */
|
|
||||||
--cv-navy-600:#22406A; /* links, accents */
|
|
||||||
--cv-accent: #C25311; /* ember — alerts, highlights, eyebrows */
|
|
||||||
|
|
||||||
/* Data-viz supporting colors (from _tokens.scss) */
|
|
||||||
--teal-600: #1F6F70;
|
|
||||||
--plum-600: #6B3A5E;
|
|
||||||
--moss-600: #4A6B2F;
|
|
||||||
--brass-600: #B8751C;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
- **Display**: Libre Franklin / Inter — headings, stat callouts.
|
|
||||||
- **Body**: Inter — UI text, labels, captions.
|
|
||||||
- **Mono**: JetBrains Mono — code snippets, API URLs in footers.
|
|
||||||
|
|
||||||
### Chart patterns (from social media posts & white paper)
|
|
||||||
1. **Ridgeline density plots** for posterior draws (`geom_density_ridges`).
|
|
||||||
2. **Pointrange / error bar charts** comparing model intervals to observed values.
|
|
||||||
3. **Faceted small multiples** — always split by `covariate × time` (baseline vs.
|
|
||||||
covariate; one-year vs. three-year).
|
|
||||||
4. **Transparent PNG export with watermark logo** in bottom-right corner.
|
|
||||||
5. **"Stat callout" hero numbers** for key metrics (e.g., total arrests, rate per 1k).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Technology Stack Recommendation
|
|
||||||
|
|
||||||
### Recommended: React + Vite + vanilla CSS (static site)
|
|
||||||
|
|
||||||
| Layer | Choice | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| Framework | **React 19** (no framework overhead; Vite dev server) | Component model for charts, built-in state management via hooks |
|
|
||||||
| Build tool | **Vite** | Fast HMR, native ES modules, trivial static export (`npm run build`) |
|
|
||||||
| Charting | **`@visx/visx`** or plain SVG/CSS animations | Lightweight, no heavy deps; we control every pixel to match Civilytics style |
|
|
||||||
| HTTP client | `fetch` with AbortController + exponential backoff | No extra dependency; browser-native |
|
|
||||||
| Styling | Plain CSS custom properties (no Tailwind) | Zero-runtime, matches the existing SCSS token system exactly |
|
|
||||||
| Deployment | Static site on any host (GitHub Pages, Vercel, Netlify) or Docker nginx container | Self-hostable; no backend required |
|
|
||||||
|
|
||||||
#### Why not Shiny / Quarto?
|
|
||||||
The API returns JSON and we need a dynamic SPA with loading states, debounced search, and animated transitions. A static React app is the most natural fit. R/Shiny would add unnecessary server-side complexity for what is fundamentally a browser-based data visualization demo. The existing `crdc-arrests` project uses Quarto + ggplot2 for reports; this demo app is a different artifact with different requirements.
|
|
||||||
|
|
||||||
#### Why not Svelte or Vue?
|
|
||||||
React has the largest ecosystem, best tooling (Vite), and most team familiarity. For a 6-chart SPA it's more than sufficient without being overkill.
|
|
||||||
|
|
||||||
### Docker option
|
|
||||||
A minimal `nginx:alpine` container serves the built static files. ~20 MB image. Can be run on your self-hosted fleet (`efron`, `maxwell`, etc.) behind Caddy/Traefik with TLS via Let's Encrypt — consistent with how you host other civilytics.org services.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Build & Deployment Strategy
|
|
||||||
|
|
||||||
### Local development
|
|
||||||
```bash
|
|
||||||
npm install # one-time: installs React, Vite, dev deps
|
|
||||||
npm run dev # starts Vite on localhost:5173
|
|
||||||
# Edit src/App.jsx / src/components/*.jsx — hot reload
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production build (static)
|
|
||||||
```bash
|
|
||||||
npm run build # outputs dist/ with index.html + assets
|
|
||||||
# Upload dist/ to any static host, or:
|
|
||||||
docker build -t crdc-demo . && docker run -p 8080:80 crdc-demo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker deployment (self-hosted on your fleet)
|
|
||||||
1. Build image locally or via CI: `docker buildx build --platform linux/amd64 -t registry.civilytics.org/crdc-demo:latest .`
|
|
||||||
2. Push to local registry on `maxwell`.
|
|
||||||
3. Deploy via docker-compose or a simple systemd service with nginx container + Caddy reverse proxy for TLS termination.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml (minimal)
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: crdc-demo:latest
|
|
||||||
ports: ["8080:80"]
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD (optional, GitHub Actions)
|
|
||||||
- On push to `main`: run lint + build, publish Docker image to local registry.
|
|
||||||
- No automated deployment — you control when new versions go live on the fleet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
crdc-demo/
|
|
||||||
├── public/ # Static assets (logo, favicon)
|
|
||||||
│ └── civilytics-logo.svg
|
|
||||||
├── src/
|
|
||||||
│ ├── components/ # Reusable UI pieces
|
|
||||||
│ │ ├── StateSelector.jsx
|
|
||||||
│ │ ├── DistrictSearch.jsx
|
|
||||||
│ │ ├── LoadingAnimation.jsx
|
|
||||||
│ │ └── StatCallout.jsx
|
|
||||||
│ ├── charts/ # The 6 chart components
|
|
||||||
│ │ ├── ArrestsOverTime.jsx
|
|
||||||
│ │ ├── RateByGroupBar.jsx
|
|
||||||
│ │ ├── DistrictVsNational.jsx
|
|
||||||
│ │ ├── ModelDrawsComparison.jsx
|
|
||||||
│ │ ├── ObservedRateDensity.jsx
|
|
||||||
│ │ └── ExceedanceProbability.jsx
|
|
||||||
│ ├── hooks/
|
|
||||||
│ │ ├── useApi.js # fetch wrapper with retry/backoff
|
|
||||||
│ │ └── useDistrictData.js # orchestrates all 6 charts' data needs
|
|
||||||
│ ├── styles/
|
|
||||||
│ │ ├── tokens.css # Civilytics design tokens
|
|
||||||
│ │ └── main.css
|
|
||||||
│ ├── App.jsx # Main router: state → search → loading → charts
|
|
||||||
│ └── main.jsx # React entry point
|
|
||||||
├── Dockerfile
|
|
||||||
├── vite.config.js
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Key Implementation Notes & Risks
|
|
||||||
|
|
||||||
### Risk 1 — API response time for multi-model data
|
|
||||||
Fetching all models × race×sex combinations for a single district requires ~40
|
|
||||||
individual `/estimates` calls (5 unified + 5 stratified models × 8 groups). Each
|
|
||||||
API call may take 200–500ms. **Mitigation**: use `Promise.allSettled()` to fire
|
|
||||||
all requests in parallel; show progress as batches resolve. The loading animation
|
|
||||||
should reflect this batching visually (e.g., rows of bars filling left-to-right).
|
|
||||||
|
|
||||||
### Risk 2 — National rate computation
|
|
||||||
The `/states` endpoint returns per-state aggregates, not a national total. To get
|
|
||||||
national arrest rates by student group for Chart 3 and Chart 6:
|
|
||||||
- **Option A**: Fetch all states (`limit=100`, iterate through ~50 pages) and sum.
|
|
||||||
Too slow for a browser app.
|
|
||||||
- **Option B**: Add a `/api/v1/national` endpoint to the API (server-side aggregate).
|
|
||||||
Requires modifying `crdc-arrests/api/` — quick R/SQL change but needs your approval.
|
|
||||||
- **Recommended short-term**: Cache national rates as a static JSON file generated
|
|
||||||
during data release and committed alongside this app. The white paper already
|
|
||||||
computes these values; we can extract them into a small fixture.
|
|
||||||
|
|
||||||
### Risk 3 — Chart complexity (Chart 5 & 6)
|
|
||||||
Charts 5 (density/ridge) and 6 (exceedance probability) require either raw posterior
|
|
||||||
draws or sufficient summary statistics to reconstruct distributions. The `/estimates`
|
|
||||||
endpoint provides `count_median`, `count_lower`, `count_upper` but not the full draw
|
|
||||||
distribution. **Mitigation**: Use interval bounds + median as a proxy for ridge plots
|
|
||||||
(showing just the 95% HPD region), and compute exceedance probability using a normal
|
|
||||||
approximation to the posterior (mean=median, sd derived from interval width). This is
|
|
||||||
a reasonable approximation for demonstration purposes but should be clearly labeled.
|
|
||||||
|
|
||||||
### Risk 4 — Mobile responsiveness
|
|
||||||
The social media figures are desktop-first PNGs. The web app must work on mobile:
|
|
||||||
- Use CSS Grid with `auto-fit` for the chart panel (1 column on mobile, 3 on desktop).
|
|
||||||
- Make the loading animation responsive.
|
|
||||||
- Ensure touch targets in district search are ≥44px.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Open Questions for You
|
|
||||||
|
|
||||||
Before implementation begins, I need your input on these decisions:
|
|
||||||
|
|
||||||
### Q1 — National rates source
|
|
||||||
Chart 3 and Chart 6 compare a district's arrest rate to the **national average**
|
|
||||||
for each student group. The API doesn't have a `/national` endpoint. How should we handle this?
|
|
||||||
|
|
||||||
- **(A)** Cache national rates as a static JSON fixture (generated from the white paper / release data). [Recommended — simplest, no API changes]
|
|
||||||
- **(B)** Add a `/api/v1/national` aggregate endpoint to `crdc-arrests/api/` and have the demo call it live.
|
|
||||||
- **(C)** Approximate by fetching the top 5 largest states' rates weighted by enrollment (fast but imprecise).
|
|
||||||
|
|
||||||
### Q2 — Model selection for distribution charts
|
|
||||||
Charts 4–6 show results from multiple model types (one-year, three-year, baseline, covariate). Should users be able to toggle which models are displayed, or should we always show all four quadrants as specified in your requirements?
|
|
||||||
|
|
||||||
- **(A)** Always show all four quadrants (1yr no-cov, 1yr cov, 3yr no-cov, 3yr cov) — matches the white paper's `wp_fig_district_intervals()` layout. [Recommended]
|
|
||||||
- **(B)** Let users toggle between unified vs. stratified models via a dropdown.
|
|
||||||
- **(C)** Default to showing only the recommended model (`unified_m2_mod`) with an "advanced" expando for all four.
|
|
||||||
|
|
||||||
### Q3 — Loading animation style
|
|
||||||
The patience animation should convey that data is being fetched across multiple endpoints/models. What visual approach do you prefer?
|
|
||||||
|
|
||||||
- **(A)** Animated grid of histogram bars (one per model×group) that fill sequentially as API calls resolve, with a counter showing "X of ~40 datasets loaded". [Recommended — matches the Bayesian posterior theme]
|
|
||||||
- **(B)** A simple spinner + text message ("Fetching 12 model results from the CRDC API…").
|
|
||||||
- **(C)** An abstract animation (e.g., particles converging) that loops while loading, with no per-request feedback.
|
|
||||||
|
|
||||||
### Q4 — Deployment target
|
|
||||||
Where should this be hosted? Your infrastructure is self-hosted (`efron`, `maxwell`, etc.), but you also mentioned civilytics.org. Which deployment approach do you want me to implement first?
|
|
||||||
|
|
||||||
- **(A)** Docker container ready for your fleet (nginx + static files) — I'll write the Dockerfile and docker-compose.yml, deploy on a dev host. [Recommended]
|
|
||||||
- **(B)** Static site optimized for GitHub Pages / Vercel with CI/CD via GitHub Actions.
|
|
||||||
- **(C)** Both A and B (Docker for self-hosting, plus GH Pages config).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Next Steps
|
|
||||||
|
|
||||||
Once you answer the four questions above, I'll begin implementation:
|
|
||||||
|
|
||||||
1. **Week 1**: Scaffold project (Vite + React), implement design tokens & typography, build state selector + district search with "interesting" suggestions.
|
|
||||||
2. **Week 2**: Implement loading animation; fetch all model data in parallel; build Charts 1–3 (observed/descriptive).
|
|
||||||
3. **Week 3**: Build Charts 4–6 (model comparison distributions); wire up national rate fixture if Q1=A.
|
|
||||||
4. **Week 4**: Polish transitions, mobile responsiveness, Dockerfile + deployment configs; write README with usage/deployment docs.
|
|
||||||
|
|
||||||
The app will be fully self-contained — no external dependencies beyond the public CRDC API and optionally a cached JSON fixture for national rates. All code follows Civilytics visual identity as documented in `theme/_tokens.scss` and demonstrated in `social_media_posts.md`.
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# CRDC Arrests API Demo
|
|
||||||
|
|
||||||
A demonstration web application for the [CRDC School Arrest Rate API](https://crdc-api.civilytics.org/api/v1/), showing Bayesian model comparisons of school-based arrest rates across U.S. districts. Built with React + Vite, deployable as a static site or Docker container.
|
|
||||||
|
|
||||||
## Quick Start (Development)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
# Start dev server on localhost:5173
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build # outputs to dist/
|
|
||||||
npm run preview # serve built files locally
|
|
||||||
```
|
|
||||||
|
|
||||||
## What It Does
|
|
||||||
|
|
||||||
Visitors select a U.S. state, search for a school district (with suggestions of districts that have the most arrests), and see 6 charts comparing observed data against Bayesian model estimates:
|
|
||||||
|
|
||||||
1. **Arrests over time** (Chart 1) — raw counts by CRDC wave with per-1k rate labels, built with inline SVG
|
|
||||||
2. **Rate by student group** (Chart 2) — bar chart, most recent year (observed vs. modeled), SVG
|
|
||||||
3. **District vs. national** (Chart 3) — highest-rate group compared to the U.S. average, SVG
|
|
||||||
4. **Model predictions vs. observed** (Chart 4) — four quadrants (one-year/three-year × baseline/covariate)
|
|
||||||
5. **Predicted rates by student group** (Chart 5) — D3 density ridges showing posterior distributions with diamond markers for observed rates
|
|
||||||
6. **Exceedance probability** (Chart 6) — P(district > national) per student group
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
- **React 19** + **Vite** (static site generation, no backend required)
|
|
||||||
- Plain CSS custom properties for styling (matches Civilytics design tokens exactly)
|
|
||||||
- D3.js v7 for data-driven visualizations (density ridges, scales, axes)
|
|
||||||
- Inline SVG rendering — charts are built with vanilla DOM/D3, not charting libraries
|
|
||||||
- Calls the public read-only API directly from the browser
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
```
|
|
||||||
crdc-demo/
|
|
||||||
├── index.html # Entry point
|
|
||||||
├── vite.config.mjs # Vite build config
|
|
||||||
├── public/ # Static assets (wordmark, favicon)
|
|
||||||
│ └── civilytics-wordmark.svg # Civilytics wordmark from civilyticsR package
|
|
||||||
├── src/
|
|
||||||
│ ├── main.jsx # React entry
|
|
||||||
│ ├── App.jsx # Main router (state → search → loading → charts)
|
|
||||||
│ ├── hooks/useApi.js # API client with retry/backoff + endpoint wrappers
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── StateSelector.jsx # Landing screen — state dropdown/grid
|
|
||||||
│ │ ├── DistrictSearch.jsx # Search + "interesting" district suggestions
|
|
||||||
│ │ ├── LoadingAnimation.jsx # Animated histogram grid during data fetch
|
|
||||||
│ │ └── ChartPanel.jsx # Orchestrates all 6 charts + data fetching
|
|
||||||
│ ├── charts/
|
|
||||||
│ │ ├── ArrestsOverTime.jsx # Chart 1 — line chart by wave (SVG)
|
|
||||||
│ │ ├── RateByGroupBar.jsx # Chart 2 — bar chart by group (SVG)
|
|
||||||
│ │ ├── DistrictVsNational.jsx # Chart 3 — comparison vs. national avg
|
|
||||||
│ │ ├── ModelDrawsComparison.jsx # Chart 4 — quadrant model comparison (D3)
|
|
||||||
│ │ ├── RateDensityRidgeline.jsx # Chart 5 — density ridges per student group (D3)
|
|
||||||
│ │ └── ExceedanceProbability.jsx # Chart 6 — P(district > nat) per group
|
|
||||||
│ ├── styles/tokens.css # Civilytics design tokens (colors, fonts, spacing)
|
|
||||||
│ └── data/national_rates.json # Static national rates fixture for comparisons
|
|
||||||
├── Dockerfile # Multi-stage build → nginx static server
|
|
||||||
├── docker-compose.yml # Local dev / self-hosted deployment
|
|
||||||
└── .gitea/workflows/pages.yml # CI/CD — builds and deploys to pages branch
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Endpoints Used
|
|
||||||
| Endpoint | Purpose | Frequency |
|
|
||||||
|---|---|---|
|
|
||||||
| `/api/v1/models` | List available Bayesian model specs | Once (cached) |
|
|
||||||
| `/api/v1/districts?q=&state=` | District name/geo lookup → LEAID | On keystroke |
|
|
||||||
| `/api/v1/estimates/{leaid}?model=X&year=Y` | Estimates for one district/model/year/group | ~40 calls per district |
|
|
||||||
| `/api/v1/draws?...` | Locate raw-posterior Parquet shard (bulk only, not used in browser) | Not called from app |
|
|
||||||
| `/data/national_rates.json` | Static national rates fixture (committed) | Once per session |
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Option A: Git Pages via Gitea Actions (recommended)
|
|
||||||
The app is a static site deployed automatically via Gitea Actions. On every push to `main`, the workflow in
|
|
||||||
`.gitea/workflows/pages.yml` builds the Vite app and copies `dist/` to a `pages` branch served by the
|
|
||||||
git-pages webhook receiver.
|
|
||||||
|
|
||||||
**URL**: `https://pages.civilytics.org/crdc-demo/`
|
|
||||||
|
|
||||||
> **CORS note**: The CRDC API (`crdc-api.civilytics.org`) does not send CORS headers. When deployed as a static site,
|
|
||||||
> browser-based fetch requests will be blocked by the same-origin policy. To fix this, deploy `proxy.php` to your git-pages host
|
|
||||||
> and set the `VITE_PROXY_URL` environment variable (e.g., `/crdc-demo/proxy.php`). The app automatically detects
|
|
||||||
> proxy availability — when unset, it attempts direct API calls (works via Docker/nginx or same-origin setups).
|
|
||||||
|
|
||||||
The workflow follows the same pattern as `Civilytics/sln-school-comparison`:
|
|
||||||
1. Triggers on push to `main` (only when source files change)
|
|
||||||
2. Builds with Node 22 + Vite (`npm ci && npm run build`)
|
|
||||||
3. Copies `dist/` contents to an orphan `pages` branch
|
|
||||||
4. Forces push — the git-pages webhook picks it up automatically
|
|
||||||
|
|
||||||
> **Note**: Ensure `${{ secrets.GITHUB_TOKEN }}` is configured in repo settings on gitea.civilytics.org.
|
|
||||||
|
|
||||||
### Option A.5: CORS Proxy for Git Pages
|
|
||||||
If you can't deploy PHP, create a simple reverse proxy in nginx or use an edge function that:
|
|
||||||
1. Accepts `?target=/api/v1/...` as a query parameter
|
|
||||||
2. Forwards the request to `https://crdc-api.civilytics.org/api/v1/...
|
|
||||||
3. Returns the response with `Access-Control-Allow-Origin: *`
|
|
||||||
4. Set `VITE_PROXY_URL` at build time to point at this endpoint.
|
|
||||||
|
|
||||||
### Option B: Docker (self-hosted fleet) — includes CORS proxy nginx config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d # builds + serves on localhost:8080
|
|
||||||
|
|
||||||
# Or build manually for your registry:
|
|
||||||
docker buildx build --platform linux/amd64 -t registry.civilytics.org/crdc-demo:latest .
|
|
||||||
docker push registry.civilytics.org/crdc-demo:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
The container is ~5MB (nginx Alpine) and serves static files with immutable cache headers. A `/healthz` endpoint supports container orchestration health checks.
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `VITE_API_BASE` | `https://crdc-api.civilytics.org/api/v1` | Override to point at a staging API (set in Dockerfile build or docker-compose) |
|
|
||||||
|
|
||||||
## Visual Design
|
|
||||||
|
|
||||||
This app follows the Civilytics visual identity as defined in:
|
|
||||||
- `theme/_tokens.scss` — colors, typography, spacing tokens
|
|
||||||
- `social_media_posts.md` — chart patterns from published social media figures
|
|
||||||
- `white_paper.qmd` / `R/paper_figures.R` — white paper figure builders
|
|
||||||
|
|
||||||
Key design decisions:
|
|
||||||
- **Color palette**: Warm paper background (`#FAF7F2`), civic navy text (`#0E1A2B`), ember accent (`#C25311`)
|
|
||||||
- **Data viz colors**: Teal, plum, moss, brass from the supporting palette
|
|
||||||
- **Fonts**: Libre Franklin/Inter for display and body, JetBrains Mono for code
|
|
||||||
- **Chart patterns**: Pointrange comparisons (model vs. observed), ridgeline-style density bars, faceted small multiples
|
|
||||||
|
|
||||||
## Citation & Attribution
|
|
||||||
|
|
||||||
The CRDC School Arrest Rate API: Knowles, J.E., & Miller, H. (2025). *CRDC School Arrest Rate API v1*. Civilytics Consulting. https://crdc-api.civilytics.org/api/v1/
|
|
||||||
|
|
||||||
Data source: US Department of Education, Office for Civil Rights. Civil Rights Data Collection (2021-22).
|
|
||||||
|
|
||||||
This research was supported by a grant from the American Educational Research Association which receives funds for its "AERA Grants Program" from the National Science Foundation under NSF award NSF-DRL #1749275. Opinions reflect those of the author and do not necessarily reflect those AERA or NSF.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Git Pages configuration — for self-hosted static site deployment.
|
|
||||||
# This file is informational; the actual build + deploy happens via .gitea/workflows/pages.yml.
|
|
||||||
# The workflow builds the Vite app, then copies dist/ to a `pages` branch served by git-pages.
|
|
||||||
|
|
||||||
title: CRDC Arrests API Demo
|
|
||||||
description: "Explore school-based arrest rates by U.S. district with Bayesian model comparisons."
|
|
||||||
author: Civilytics Consulting LLC
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
var e=`/crdc-demo/assets/duckdb-browser-mvp.worker-C9hF7LGh.js`;export{e as default};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
var e=`/crdc-demo/assets/duckdb-mvp-BP0pRkMH.wasm`;export{e as default};
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
654b42ca71f3654156e9b7a5e669dfa3c2fff41f
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Docker Compose for local development or self-hosted deployment on your fleet.
|
|
||||||
# Usage: docker compose up -d
|
|
||||||
|
|
||||||
services:
|
|
||||||
crdc-demo:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "8080:80" # Map host port 8080 → container port 80 (nginx)
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
# Override to point at a staging API if needed; defaults to production.
|
|
||||||
VITE_API_BASE: https://crdc-api.civilytics.org/api/v1
|
|
||||||
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
+2
-1
@@ -5,10 +5,11 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/crdc-demo/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/crdc-demo/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CRDC Arrests API Demo — Civilytics</title>
|
<title>CRDC Arrests API Demo — Civilytics</title>
|
||||||
|
<script type="module" crossorigin src="/crdc-demo/assets/index-naTE57ov.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/crdc-demo/assets/index-DtZIgSE6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- Vite injects the correct script tag with base-relative path -->
|
<!-- Vite injects the correct script tag with base-relative path -->
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
# nginx config for CRDC Arrests Demo — static SPA + API reverse proxy with CORS.
|
|
||||||
# When served via Docker, this proxies /api/v1/ requests to the CRDC API server-side,
|
|
||||||
# adding Access-Control-Allow-Origin: * so browser-based fetch works without issues.
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
# Proxy all /api/v1/ requests to the CRDC Arrest Rate API with CORS headers
|
|
||||||
location /api/v1/ {
|
|
||||||
proxy_pass https://crdc-api.civilytics.org/api/v1/;
|
|
||||||
proxy_set_header Host crdc-api.civilytics.org;
|
|
||||||
proxy_ssl_verify off;
|
|
||||||
|
|
||||||
# Add CORS headers so browser-based requests work
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
||||||
add_header Access-Control-Allow-Headers "*" always;
|
|
||||||
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Also support a simple proxy endpoint for git-pages-style deployments
|
|
||||||
location /crdc-demo/proxy/ {
|
|
||||||
# Extract the target path from query string and forward to CRDC API
|
|
||||||
proxy_pass https://crdc-api.civilytics.org$arg_target;
|
|
||||||
proxy_set_header Host crdc-api.civilytics.org;
|
|
||||||
proxy_ssl_verify off;
|
|
||||||
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files from the Vite build output
|
|
||||||
root /usr/share/nginx/html/crdc-demo/; # Adjust if base is different
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Civilytics design tokens: immutable cache headers for static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2|ttf)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check endpoint for container orchestration
|
|
||||||
location /healthz {
|
|
||||||
access_log off;
|
|
||||||
return 200 "ok";
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-2348
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "crdc-arrests-demo",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "CRDC School Arrest Rate API demonstration app — shows Bayesian model comparisons for school-based arrest rates across U.S. districts.",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"lint": "eslint src/ --ext .js,.jsx,.ts,.tsx",
|
|
||||||
"format": "prettier --write \"src/**/*.{js,jsx,css}\""
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"crdc",
|
|
||||||
"school-arrests",
|
|
||||||
"api-demo",
|
|
||||||
"civilytics"
|
|
||||||
],
|
|
||||||
"author": "Civilytics Consulting LLC",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react-swc": "^4.3.3",
|
|
||||||
"eslint": "^8.57.1",
|
|
||||||
"prettier": "^3.9.6",
|
|
||||||
"react": "^19.2.8",
|
|
||||||
"react-dom": "^19.2.8",
|
|
||||||
"vite": "^8.2.1"
|
|
||||||
},
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Simple CORS proxy for CRDC Arrest Rate API.
|
|
||||||
*
|
|
||||||
* Deploy this file to your git-pages host alongside the static site (e.g., at /crdc-demo/proxy.php).
|
|
||||||
* Then set VITE_PROXY_URL=/crdc-demo/proxy.php in your build environment.
|
|
||||||
*
|
|
||||||
* Usage: GET /proxy.php?target=/api/v1/districts?q=Denver&state=CO
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Allow cross-origin requests from any origin
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: *');
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
||||||
http_response_code(204);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and validate the target path from query string
|
|
||||||
$target = isset($_GET['target']) ? $_GET['target'] : '';
|
|
||||||
if (empty($target)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Missing "target" parameter']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the full API URL — only allow requests to crdc-api.civilytics.org for security
|
|
||||||
$apiBase = 'https://crdc-api.civilytics.org';
|
|
||||||
$url = $apiBase . $target;
|
|
||||||
|
|
||||||
// Validate that we're not proxying arbitrary URLs (prevent SSRF)
|
|
||||||
if (!str_starts_with($url, $apiBase . '/api/v1/')) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['error' => 'Invalid target — must be under /api/v1/']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the API response and return it with CORS headers
|
|
||||||
$ch = curl_init($url);
|
|
||||||
curl_setopt_array($ch, [
|
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
|
||||||
CURLOPT_TIMEOUT => 30,
|
|
||||||
CURLOPT_SSL_VERIFYPEER => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
// Forward the response with appropriate status code and CORS headers
|
|
||||||
http_response_code($httpCode);
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
echo $response;
|
|
||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import StateSelector from './components/StateSelector.jsx'
|
|
||||||
import DistrictSearch from './components/DistrictSearch.jsx'
|
|
||||||
import LoadingAnimation from './components/LoadingAnimation.jsx'
|
|
||||||
import ChartPanel from './components/ChartPanel.jsx'
|
|
||||||
import Footer from './components/Footer.jsx'
|
|
||||||
import { fetchDistrictEstimates } from './hooks/useApi.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CRDC Arrests API Demo App — main router.
|
|
||||||
* Flow: state → district search (with interesting suggestions) → loading animation → 6 charts
|
|
||||||
*/
|
|
||||||
export default function App() {
|
|
||||||
const [step, setStep] = useState('state') // 'state' | 'search' | 'loading' | 'results'
|
|
||||||
const [selectedState, setSelectedState] = useState(null)
|
|
||||||
const [district, setDistrict] = useState(null)
|
|
||||||
|
|
||||||
// On first load: check URL for ?leaid= and state to allow deep-linking
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
|
||||||
const leaid = params.get('leaid')
|
|
||||||
const stateParam = params.get('state')
|
|
||||||
if (leaid && stateParam) {
|
|
||||||
// Deep link (including browser back/forward landing on this URL): resolve
|
|
||||||
// the district name via a real estimates row, keyed by LEAID. The previous
|
|
||||||
// version called searchDistricts(leaid, ...), a name/text search — passing
|
|
||||||
// an LEAID as search text never matches, so it always fell back to "Unknown
|
|
||||||
// District".
|
|
||||||
setSelectedState(stateParam)
|
|
||||||
|
|
||||||
fetchDistrictEstimates(leaid, { model: 'unified_m2_mod', year: '21-22' }).then(rows => {
|
|
||||||
const row = rows?.[0]
|
|
||||||
setDistrict(row
|
|
||||||
? { leaid: row.leaid, lea_name: row.lea_name, state: row.state }
|
|
||||||
: { leaid, lea_name: 'Unknown District', state: stateParam })
|
|
||||||
setStep('loading')
|
|
||||||
}).catch(() => {
|
|
||||||
// Fallback on error
|
|
||||||
setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam })
|
|
||||||
setStep('loading')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleStateChange = (stateCode) => {
|
|
||||||
setSelectedState(stateCode)
|
|
||||||
setStep('search')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDistrictSelect = (dist) => {
|
|
||||||
// Update URL for shareability
|
|
||||||
const params = new URLSearchParams({ leaid: dist.leaid, state: selectedState })
|
|
||||||
window.history.replaceState(null, '', `?${params}`)
|
|
||||||
setDistrict(dist)
|
|
||||||
setStep('loading')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-app">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="cv-header cv-wrap">
|
|
||||||
<a href="/crdc-demo" aria-label="Civilytics" style={{ display: 'block' }}>
|
|
||||||
<img
|
|
||||||
src={import.meta.env.BASE_URL + 'civilytics-wordmark.svg'}
|
|
||||||
alt="Civilytics — social science for the public good"
|
|
||||||
style={{ height: '2.5rem', width: 'auto' }}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main style={{ maxWidth: '60rem', margin: '0 auto', padding: '0 var(--space-3)' }}>
|
|
||||||
{step === 'state' && (
|
|
||||||
<StateSelector onNext={handleStateChange} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'search' && selectedState && (
|
|
||||||
<DistrictSearch
|
|
||||||
state={selectedState}
|
|
||||||
onSelect={handleDistrictSelect}
|
|
||||||
onBack={() => setStep('state')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'loading' && district && (
|
|
||||||
<LoadingAnimation district={district} state={selectedState} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ChartPanel listens for data-ready event from LoadingAnimation */}
|
|
||||||
{step === 'results' && district && (
|
|
||||||
<ChartPanel district={district} state={selectedState} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(step === 'loading' || step === 'results') && (
|
|
||||||
<button
|
|
||||||
className="btn-outline"
|
|
||||||
style={{ margin: 'var(--space-3) 0', display: 'block' }}
|
|
||||||
onClick={() => { window.location.href = '/crdc-demo/' }}
|
|
||||||
>
|
|
||||||
← Search another district
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import ChartLegend from '../components/ChartLegend.jsx'
|
|
||||||
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
|
|
||||||
import { niceTicks } from '../utils/niceTicks.js'
|
|
||||||
import { MODEL_QUADRANT_LABEL } from '../utils/labels.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chart 1: Observed arrests by CRDC wave (line, diamond markers — the same
|
|
||||||
* "diamond = observed" convention as every other chart), overlaid with one
|
|
||||||
* model's predicted total (point-range) per wave, aligned to the same x
|
|
||||||
* position as its observed point rather than dodged aside, so the two are
|
|
||||||
* read as a pair.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
|
||||||
const HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it
|
|
||||||
|
|
||||||
export default function ArrestsOverTime({ data, modelId }) {
|
|
||||||
const maxArrests = Math.max(
|
|
||||||
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
|
|
||||||
1
|
|
||||||
)
|
|
||||||
const { ticks, niceMax } = niceTicks(maxArrests)
|
|
||||||
const modelLabel = MODEL_QUADRANT_LABEL[modelId] || modelId
|
|
||||||
|
|
||||||
const width = 360
|
|
||||||
const height = 280
|
|
||||||
const margin = { top: 40, right: 40, bottom: 60, left: 65 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
const innerHeight = height - margin.top - margin.bottom
|
|
||||||
|
|
||||||
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
|
|
||||||
const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 0, color: 'var(--cv-ink-2)' }}>
|
|
||||||
Arrests over time — observed vs. modeled
|
|
||||||
</h3>
|
|
||||||
{modelLabel && (
|
|
||||||
<p style={{ fontSize: '0.78rem', fontStyle: 'italic', color: 'var(--cv-ink-3)', margin: '0.15rem 0 0' }}>
|
|
||||||
Modeled intervals use the {modelLabel.toLowerCase()} model.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%', marginTop: 'var(--space-1)' }}>
|
|
||||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
|
|
||||||
fill="var(--cv-paper-2)" rx={4} />
|
|
||||||
|
|
||||||
{ticks.map((val) => {
|
|
||||||
const y = margin.top + yScale(val)
|
|
||||||
return (
|
|
||||||
<g key={`y-${val}`}>
|
|
||||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
|
|
||||||
stroke="var(--cv-rule)" strokeWidth={1} />
|
|
||||||
<text x={margin.left - 8} y={y + 4} textAnchor="end"
|
|
||||||
fontSize="0.7rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x={15} y={margin.top + innerHeight / 2} textAnchor="middle"
|
|
||||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 15 ${margin.top + innerHeight / 2})`}>
|
|
||||||
Total arrests (count)
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{data.map((d, i) => (
|
|
||||||
<text key={d.year} x={margin.left + xScale(i)} y={height - margin.bottom + 15}
|
|
||||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">
|
|
||||||
{WAVE_LABELS[d.year] || d.label}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
|
|
||||||
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
|
|
||||||
|
|
||||||
{/* Modeled point-range per wave — drawn first, directly under the observed marks it pairs with */}
|
|
||||||
{data.map((d, i) => {
|
|
||||||
if (d.modeledMedian == null) return null
|
|
||||||
const cx = margin.left + xScale(i)
|
|
||||||
const cyMedian = margin.top + yScale(d.modeledMedian)
|
|
||||||
const cyLower = margin.top + yScale(d.modeledLower ?? d.modeledMedian)
|
|
||||||
const cyUpper = margin.top + yScale(d.modeledUpper ?? d.modeledMedian)
|
|
||||||
return (
|
|
||||||
<g key={`modeled-${d.year}`}>
|
|
||||||
<line x1={cx} y1={cyLower} x2={cx} y2={cyUpper} stroke={MODELED_AGGREGATE_COLOR} strokeWidth={2} />
|
|
||||||
<circle cx={cx} cy={cyMedian} r={4.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1.25} />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Observed line */}
|
|
||||||
{data.length > 1 && (
|
|
||||||
<polyline
|
|
||||||
points={data.map((d, i) => `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
|
|
||||||
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2.5}
|
|
||||||
strokeLinejoin="round" strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Observed diamonds + rate-per-1k labels (halo behind the text keeps
|
|
||||||
it legible over the modeled whisker sharing the same column) */}
|
|
||||||
{data.map((d, i) => {
|
|
||||||
const cx = margin.left + xScale(i)
|
|
||||||
const cy = margin.top + yScale(d.arrests)
|
|
||||||
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.0'
|
|
||||||
// The first/last points sit flush on the plot's left/right edge, so a
|
|
||||||
// center-anchored label above them would overflow into the y-axis
|
|
||||||
// ticks or off the right edge — anchor those two outward instead.
|
|
||||||
const isFirst = i === 0
|
|
||||||
const isLast = i === data.length - 1
|
|
||||||
const anchor = isFirst ? 'start' : isLast ? 'end' : 'middle'
|
|
||||||
const labelX = isFirst ? cx + 7 : isLast ? cx - 7 : cx
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={d.year}>
|
|
||||||
<rect x={cx - 5} y={cy - 5} width={10} height={10}
|
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5}
|
|
||||||
transform={`rotate(45 ${cx} ${cy})`} />
|
|
||||||
<text x={labelX} y={cy - 16} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
|
|
||||||
fill="var(--cv-ink)" stroke="var(--cv-paper-2)" strokeWidth={3}
|
|
||||||
strokeLinejoin="round" paintOrder="stroke">{ratePerK}</text>
|
|
||||||
<text x={labelX} y={cy - 7} textAnchor={anchor} fontSize="0.6rem"
|
|
||||||
fill="var(--cv-ink-3)" stroke="var(--cv-paper-2)" strokeWidth={3}
|
|
||||||
strokeLinejoin="round" paintOrder="stroke">per 1k</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<ChartLegend items={[
|
|
||||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
|
||||||
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
|
||||||
Rate per 1,000 students labeled above each observed point. The modeled total sums each
|
|
||||||
selected group's median/interval independently, which approximates but is not exactly the
|
|
||||||
median of the combined total. Data from CRDC waves 2015–16 through 2021–22.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import ChartLegend from '../components/ChartLegend.jsx'
|
|
||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
|
||||||
import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js'
|
|
||||||
import { fitSkewedInterval } from '../utils/distributionApprox.js'
|
|
||||||
import { niceTicks } from '../utils/niceTicks.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arrest rate by student group, most recent year, disaggregated into two
|
|
||||||
* panels (Female / Male), each a horizontal box-and-whisker across the 4
|
|
||||||
* race categories. Whisker = the model's reported 90% interval, box = the
|
|
||||||
* fitted approximation's 25th-75th percentile, white tick = median, dark
|
|
||||||
* diamond = observed rate.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
|
||||||
const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
|
||||||
const ROW_HEIGHT = 34
|
|
||||||
|
|
||||||
function buildBox(d) {
|
|
||||||
const median = Math.max(d.modeledMedian || 0, 0)
|
|
||||||
const lower = Math.max(Math.min(d.rateLower ?? median, median), 0)
|
|
||||||
const upper = Math.max(d.rateUpper ?? median, median)
|
|
||||||
const fit = fitSkewedInterval({ median, lower, upper })
|
|
||||||
return {
|
|
||||||
lower, upper, median,
|
|
||||||
q1: Math.max(fit.quantile(0.25), 0),
|
|
||||||
q3: Math.max(fit.quantile(0.75), median),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RateByGroupBar({ data }) {
|
|
||||||
const maxRate = Math.max(
|
|
||||||
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
|
|
||||||
0.5
|
|
||||||
)
|
|
||||||
const { ticks, niceMax } = niceTicks(maxRate, 4)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
|
||||||
Arrest rate by student group — 2021–22 (per 1,000)
|
|
||||||
</h3>
|
|
||||||
<ApproxNote />
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
|
||||||
{SEX_PANELS.map(({ sex, label }) => (
|
|
||||||
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} ticks={ticks} niceMax={niceMax} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChartLegend items={[
|
|
||||||
...RACE_ORDER.filter((race) => data.some((d) => d.race === race)).map((race) => ({
|
|
||||||
shape: 'swatch', color: raceColor(race), label: SHORT_RACE_LABEL[race],
|
|
||||||
})),
|
|
||||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
|
||||||
Box = modeled 25th–75th percentile (fitted approximation); whisker = the model's reported
|
|
||||||
90% interval; white tick = median. The dark diamond is the observed rate.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SexPanel({ label, rows, ticks, niceMax }) {
|
|
||||||
const width = 300
|
|
||||||
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean)
|
|
||||||
const bodyHeight = byRace.length * ROW_HEIGHT
|
|
||||||
const height = margin.top + bodyHeight + margin.bottom
|
|
||||||
const xScale = (val) => margin.left + (val / niceMax) * innerWidth
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
|
||||||
<text x={width / 2} y={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
|
|
||||||
|
|
||||||
{ticks.map((val) => {
|
|
||||||
const x = xScale(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
|
||||||
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">
|
|
||||||
{val}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{byRace.map((d, i) => {
|
|
||||||
const rowY = margin.top + i * ROW_HEIGHT
|
|
||||||
const midY = rowY + ROW_HEIGHT / 2
|
|
||||||
const boxTop = midY - ROW_HEIGHT * 0.26
|
|
||||||
const boxBottom = midY + ROW_HEIGHT * 0.26
|
|
||||||
const color = raceColor(d.race)
|
|
||||||
const box = buildBox(d)
|
|
||||||
const observedX = xScale(d.observedRate)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={d.race}>
|
|
||||||
<line x1={xScale(box.lower)} y1={midY} x2={xScale(box.upper)} y2={midY} stroke={color} strokeWidth={1.5} />
|
|
||||||
<line x1={xScale(box.lower)} y1={boxTop} x2={xScale(box.lower)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
|
||||||
<line x1={xScale(box.upper)} y1={boxTop} x2={xScale(box.upper)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
|
||||||
<rect
|
|
||||||
x={xScale(box.q1)} y={boxTop} width={Math.max(xScale(box.q3) - xScale(box.q1), 1)} height={boxBottom - boxTop}
|
|
||||||
fill={color} fillOpacity={0.55} stroke={color} strokeWidth={1} rx={1.5}
|
|
||||||
/>
|
|
||||||
<line x1={xScale(box.median)} y1={boxTop} x2={xScale(box.median)} y2={boxBottom} stroke="#fff" strokeWidth={1.5} />
|
|
||||||
<rect
|
|
||||||
x={observedX - 4} y={midY - 4} width={8} height={8}
|
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
|
||||||
transform={`rotate(45 ${observedX} ${midY})`}
|
|
||||||
/>
|
|
||||||
<text x={margin.left - 8} y={midY + 4} textAnchor="end" fontSize="0.72rem" fill="var(--cv-ink)">
|
|
||||||
{SHORT_RACE_LABEL[d.race] || d.race}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x={margin.left + innerWidth / 2} y={height - 6} textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-3)">
|
|
||||||
Rate per 1,000 students
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
|
||||||
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
|
|
||||||
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
|
|
||||||
import { niceTicks } from '../utils/niceTicks.js'
|
|
||||||
import ChartLegend from '../components/ChartLegend.jsx'
|
|
||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modeled posterior density per race×sex group, for one selected model
|
|
||||||
* (dropdown, default three-year + referral rate), split into Female/Male
|
|
||||||
* columns — matches whitepaper-fig-clark-density-1.png's ridge style. Plain
|
|
||||||
* SVG — each ridge is 60 analytic points from fitSkewedInterval/densityCurve,
|
|
||||||
* already smooth without a binning/curveBasis smoothing pass.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
|
||||||
const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
|
||||||
const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate
|
|
||||||
|
|
||||||
function buildGroupRow(row) {
|
|
||||||
const enroll = row.stu_enroll || 0
|
|
||||||
const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0
|
|
||||||
const rateMedian = (row.rate_median || 0) * 1000
|
|
||||||
const rateLower = (row.rate_lower || 0) * 1000
|
|
||||||
const rateUpper = (row.rate_upper || 0) * 1000
|
|
||||||
return {
|
|
||||||
race: row.race,
|
|
||||||
sex: row.sex,
|
|
||||||
observedRate,
|
|
||||||
fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RateDensityRidgeline({ quadData }) {
|
|
||||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
|
|
||||||
const rows = (quadData && quadData[selectedModel]) || []
|
|
||||||
|
|
||||||
const modelSelect = (
|
|
||||||
<select
|
|
||||||
value={selectedModel}
|
|
||||||
onChange={(e) => setSelectedModel(e.target.value)}
|
|
||||||
style={{ padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)', fontSize: '0.8rem' }}
|
|
||||||
>
|
|
||||||
{MODEL_QUADRANTS.map((q) => (
|
|
||||||
<option key={q.model} value={q.model}>{q.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
|
|
||||||
<div>
|
|
||||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
|
||||||
Predicted arrest rates by student group
|
|
||||||
</h3>
|
|
||||||
<ApproxNote />
|
|
||||||
</div>
|
|
||||||
{modelSelect}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>No model data available.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RidgeColumns rows={rows} />
|
|
||||||
<ChartLegend items={[
|
|
||||||
...RACE_ORDER.map((race) => ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })),
|
|
||||||
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
|
||||||
]} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RidgeColumns({ rows }) {
|
|
||||||
// Shared x-domain across both columns, so Female/Male are directly comparable.
|
|
||||||
const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000)
|
|
||||||
const allObserved = rows
|
|
||||||
.filter((r) => (r.stu_enroll || 0) > 0)
|
|
||||||
.map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000)
|
|
||||||
const rawMax = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
|
|
||||||
const { ticks, niceMax } = niceTicks(rawMax, 5)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
|
||||||
{SEX_COLUMNS.map(({ sex, label }) => (
|
|
||||||
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} ticks={ticks} maxRate={niceMax} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SexRidgeColumn({ label, rows, ticks, maxRate }) {
|
|
||||||
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow)
|
|
||||||
|
|
||||||
const width = 300
|
|
||||||
const rowHeight = 58
|
|
||||||
const margin = { top: 26, right: 16, bottom: 26, left: 56 }
|
|
||||||
const innerWidth = width - margin.left - margin.right
|
|
||||||
const height = margin.top + Math.max(groups.length, 1) * rowHeight + margin.bottom
|
|
||||||
|
|
||||||
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
|
||||||
|
|
||||||
const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }))
|
|
||||||
const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9)
|
|
||||||
const peakHeight = rowHeight * 0.82
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
|
||||||
<text x={width / 2} y={16} textAnchor="middle" fontSize="0.78rem" fontWeight={700} fill="var(--cv-ink-2)">
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{groups.length === 0 ? (
|
|
||||||
<text x={width / 2} y={height / 2} textAnchor="middle" fontSize="0.65rem" fill="var(--cv-ink-3)">
|
|
||||||
No data for this group
|
|
||||||
</text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{ticks.map((val) => {
|
|
||||||
const x = xScale(val)
|
|
||||||
return (
|
|
||||||
<g key={val}>
|
|
||||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
|
||||||
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
|
|
||||||
{val}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{groups.map((g, i) => {
|
|
||||||
const rowTop = margin.top + i * rowHeight
|
|
||||||
const baselineY = rowTop + rowHeight * 0.9
|
|
||||||
const curve = curves[i]
|
|
||||||
const color = raceColor(g.race)
|
|
||||||
|
|
||||||
const topPath = curve
|
|
||||||
.map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`)
|
|
||||||
.join(' ')
|
|
||||||
const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z`
|
|
||||||
|
|
||||||
const obsX = xScale(Math.min(g.observedRate, maxRate))
|
|
||||||
const obsY = baselineY - peakHeight * 0.15
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={g.race}>
|
|
||||||
<text x={margin.left - 8} y={rowTop + rowHeight / 2 + 4} textAnchor="end" fontSize="0.68rem" fill="var(--cv-ink)">
|
|
||||||
{SHORT_RACE_LABEL[g.race] || g.race}
|
|
||||||
</text>
|
|
||||||
<path d={areaPath} fill={color} opacity={0.6} stroke="#fff" strokeWidth={0.5} />
|
|
||||||
<rect
|
|
||||||
x={obsX - 4} y={obsY - 4} width={8} height={8}
|
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
|
||||||
transform={`rotate(45 ${obsX} ${obsY})`}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x={width / 2} y={height - 6} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
|
|
||||||
Arrests per 1,000 students
|
|
||||||
</text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { DISTRIBUTION_APPROX_NOTE } from '../utils/distributionApprox.js'
|
|
||||||
|
|
||||||
/** Consistent caption for any chart that renders an approximated distribution shape. */
|
|
||||||
export default function ApproxNote() {
|
|
||||||
return (
|
|
||||||
<p style={{ fontSize: '0.78rem', fontStyle: 'italic', color: 'var(--cv-ink-3)', margin: '0.25rem 0 0' }}>
|
|
||||||
{DISTRIBUTION_APPROX_NOTE}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* Plain-HTML chart legend — replaces every chart's hand-rolled SVG legend.
|
|
||||||
* Both of the app's stray-`}` CSS bugs lived inside hand-written SVG legend
|
|
||||||
* `fill="..."` strings; keeping legends out of SVG removes that bug class.
|
|
||||||
*
|
|
||||||
* items: [{ color, label, shape: 'swatch' | 'diamond' | 'line' | 'dot' }]
|
|
||||||
*/
|
|
||||||
export default function ChartLegend({ items }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.25rem', marginTop: 'var(--space-2)' }}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.label} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
||||||
<LegendMark shape={item.shape} color={item.color} />
|
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--cv-ink-2)' }}>{item.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LegendMark({ shape, color }) {
|
|
||||||
if (shape === 'diamond') {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '9px',
|
|
||||||
height: '9px',
|
|
||||||
background: color,
|
|
||||||
transform: 'rotate(45deg)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (shape === 'line') {
|
|
||||||
return <span style={{ display: 'inline-block', width: '16px', height: '3px', background: color, borderRadius: '2px', flexShrink: 0 }} />
|
|
||||||
}
|
|
||||||
if (shape === 'dot') {
|
|
||||||
return <span style={{ display: 'inline-block', width: '10px', height: '10px', background: color, borderRadius: '50%', flexShrink: 0 }} />
|
|
||||||
}
|
|
||||||
return <span style={{ display: 'inline-block', width: '11px', height: '11px', background: color, borderRadius: '3px', flexShrink: 0 }} />
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import * as api from '../hooks/useApi.js'
|
|
||||||
import { groupLabel } from '../utils/colors.js'
|
|
||||||
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
|
|
||||||
import RateByGroupBar from '../charts/RateByGroupBar.jsx'
|
|
||||||
import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx'
|
|
||||||
|
|
||||||
const ALL_WAVES = ['15-16', '17-18', '21-22']
|
|
||||||
const QUADRANT_MODELS = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
|
|
||||||
const WAVE_MODEL = 'unified_m3_mod' // three-year, no covariate — powers Charts 1 & 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest
|
|
||||||
* rate by student group (Female/Male panels), and the model-selectable
|
|
||||||
* posterior density ridge chart.
|
|
||||||
*/
|
|
||||||
export default function ChartPanel({ district, state }) {
|
|
||||||
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
// Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod)
|
|
||||||
const waveData = {}
|
|
||||||
await Promise.all(ALL_WAVES.map(async (year) => {
|
|
||||||
try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: WAVE_MODEL, year }) } catch (e) {}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Chart 3: all 4 quadrant models, so the dropdown can switch between them
|
|
||||||
const quadData = {}
|
|
||||||
await Promise.all(QUADRANT_MODELS.map(async (model) => {
|
|
||||||
try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {}
|
|
||||||
}))
|
|
||||||
|
|
||||||
setData({ waveData, quadData })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('ChartPanel data fetch failed:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
}, [district.leaid])
|
|
||||||
|
|
||||||
if (loading || !data) {
|
|
||||||
return <LoadingCharts />
|
|
||||||
}
|
|
||||||
|
|
||||||
const mostRecent = data.waveData['21-22'] || []
|
|
||||||
|
|
||||||
// Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range
|
|
||||||
const timeSeriesData = ALL_WAVES.map((year) => {
|
|
||||||
const yearRows = data.waveData[year] || []
|
|
||||||
return {
|
|
||||||
year,
|
|
||||||
label: `20${year.replace('-', '-')}`,
|
|
||||||
arrests: yearRows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0),
|
|
||||||
enroll: yearRows.reduce((sum, r) => sum + (r.stu_enroll || 0), 0),
|
|
||||||
modeledMedian: yearRows.reduce((sum, r) => sum + (r.count_median || 0), 0),
|
|
||||||
modeledLower: yearRows.reduce((sum, r) => sum + (r.count_lower || 0), 0),
|
|
||||||
modeledUpper: yearRows.reduce((sum, r) => sum + (r.count_upper || 0), 0),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Chart 2: rate by student group (most recent wave, per 1k), with modeled + observed + interval
|
|
||||||
const rateByGroup = mostRecent.map((r) => ({
|
|
||||||
race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex),
|
|
||||||
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
|
|
||||||
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
|
|
||||||
rateLower: (r.rate_lower || 0) * 1000,
|
|
||||||
rateUpper: (r.rate_upper || 0) * 1000,
|
|
||||||
observedArrests: r.observed_arrests || 0,
|
|
||||||
enrollment: r.stu_enroll || 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-3) 0 var(--space-7)' }}>
|
|
||||||
{/* Chart panel header */}
|
|
||||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
|
||||||
<span className="eyebrow">District estimates — Bayesian model comparison</span>
|
|
||||||
<h2 style={{ marginTop: 'var(--space-1)', marginBottom: 0 }}>
|
|
||||||
{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 'var(--space-5)',
|
|
||||||
maxWidth: '70rem',
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginRight: 'auto'
|
|
||||||
}}>
|
|
||||||
<ArrestsOverTime data={timeSeriesData} modelId={WAVE_MODEL} />
|
|
||||||
|
|
||||||
<RateByGroupBar data={rateByGroup} />
|
|
||||||
|
|
||||||
<RateDensityRidgeline quadData={data.quadData} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Methodology footer */}
|
|
||||||
<div style={{ marginTop: 'var(--space-6)', padding: 'var(--space-3) 0', borderTop: '1px solid var(--cv-rule)' }}>
|
|
||||||
<span className="eyebrow">Methodology</span>
|
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
|
|
||||||
Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown spans three waves of
|
|
||||||
the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets you explore four Bayesian model
|
|
||||||
specifications: one-year vs. three-year models with and without referral-rate covariates. All rates are
|
|
||||||
per 1,000 students.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingCharts() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)', textAlign: 'center' }}>
|
|
||||||
<span className="eyebrow">Preparing charts</span>
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)' }}>Organizing data across all model specifications…</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { searchDistricts, fetchStateDistricts } from '../hooks/useApi.js'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* District search screen with:
|
|
||||||
* 1. "Interesting" suggestions — districts with the most arrests in the selected state (fetched once)
|
|
||||||
* 2. Live-search as you type → /api/v1/districts?q=...&state=XX
|
|
||||||
*/
|
|
||||||
export default function DistrictSearch({ state, onSelect, onBack }) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const [searchResults, setSearchResults] = useState([])
|
|
||||||
const [suggestions, setSuggestions] = useState([]) // "interesting" districts sorted by arrests desc
|
|
||||||
const [loadingSugg, setLoadingSugg] = useState(true)
|
|
||||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
|
||||||
|
|
||||||
// ——— Fetch interesting suggestions (top arrests in this state) once on mount ———
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
async function loadSuggestions() {
|
|
||||||
try {
|
|
||||||
// /estimates?state=XX&year=21-22 returns all districts; sort by observed_arrests desc client-side
|
|
||||||
const data = await fetchStateDistricts(state, '21-22', 500)
|
|
||||||
|
|
||||||
// Aggregate arrests per district (sum across race×sex groups), then sort
|
|
||||||
const byLeaid = {}
|
|
||||||
for (const row of data) {
|
|
||||||
if (!byLeaid[row.leaid]) {
|
|
||||||
byLeaid[row.leaid] = { leaid: row.leaid, lea_name: row.lea_name, state: row.state, observed_arrests: 0 }
|
|
||||||
}
|
|
||||||
byLeaid[row.leaid].observed_arrests += (row.observed_arrests || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = Object.values(byLeaid).sort((a, b) => b.observed_arrests - a.observed_arrests)
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
// Take top 8 for suggestions; filter to those with >0 arrests
|
|
||||||
setSuggestions(sorted.filter(d => d.observed_arrests > 0).slice(0, 8))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load interesting districts:', err)
|
|
||||||
if (!cancelled) setSuggestions([]) // degrade gracefully — just show search box
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoadingSugg(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSuggestions()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [state])
|
|
||||||
|
|
||||||
// ——— Live search as user types (debounced by React's natural event batching) ———
|
|
||||||
useEffect(() => {
|
|
||||||
if (!query.trim()) { setSearchResults([]); return }
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
setLoadingSearch(true)
|
|
||||||
try {
|
|
||||||
const data = await searchDistricts(query, state)
|
|
||||||
setSearchResults(data || [])
|
|
||||||
} catch (err) {
|
|
||||||
console.error('District search failed:', err)
|
|
||||||
setSearchResults([])
|
|
||||||
} finally {
|
|
||||||
setLoadingSearch(false)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [query, state])
|
|
||||||
|
|
||||||
// ——— Render a district card (used for both suggestions and search results) ———
|
|
||||||
const renderDistrictCard = (dist) => (
|
|
||||||
<button
|
|
||||||
key={dist.leaid}
|
|
||||||
className="cv-card"
|
|
||||||
style={{ textAlign: 'left', padding: 'var(--space-2)' }}
|
|
||||||
onClick={() => onSelect(dist)}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
||||||
<span style={{ fontWeight: 600, color: 'var(--cv-navy-700)', fontSize: '0.95rem' }}>
|
|
||||||
{dist.lea_name || dist.name}
|
|
||||||
</span>
|
|
||||||
{dist.observed_arrests !== undefined && (
|
|
||||||
<span className="stat-label accent" style={{ marginTop: 0 }}>
|
|
||||||
{dist.observed_arrests.toLocaleString()} arrests in 2021–22
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
|
|
||||||
{/* Back button */}
|
|
||||||
<button className="btn-outline" onClick={onBack} style={{ marginBottom: 'var(--space-3)' }}>
|
|
||||||
← Change state
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2 style={{ marginTop: 0, marginBottom: 'var(--space-1)' }}>
|
|
||||||
{state === 'DC' ? "District of Columbia" : `Find a district in ${getStateName(state)}`}
|
|
||||||
</h2>
|
|
||||||
<p style={{ color: 'var(--cv-ink-2)', marginBottom: 'var(--space-3)' }}>
|
|
||||||
Search by district name, or try one of the suggested districts with the highest arrest counts.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* "Interesting" suggestions */}
|
|
||||||
{!query && (
|
|
||||||
<div style={{ marginTop: 'var(--space-4)' }}>
|
|
||||||
<span className="eyebrow">Suggested districts — most arrests in 2021–22</span>
|
|
||||||
{loadingSugg ? (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>Loading suggestions…</p>
|
|
||||||
) : suggestions.length > 0 ? (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-1)' }}>
|
|
||||||
{suggestions.map(renderDistrictCard)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)', padding: 'var(--space-2) 0' }}>
|
|
||||||
No districts with reported arrests found in this state. Try searching by name below.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Live search */}
|
|
||||||
<div style={{ marginTop: query ? 0 : 'var(--space-5)' }}>
|
|
||||||
<label htmlFor="district-search" className="stat-label">Search district name:</label>
|
|
||||||
<input
|
|
||||||
id="district-search"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type a district name… (e.g., Madison, Derby, Paterson)"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
|
|
||||||
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
|
|
||||||
fontFamily: 'var(--font-sans)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{query && (
|
|
||||||
<div style={{ marginTop: 'var(--space-2)' }}>
|
|
||||||
{loadingSearch ? (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)' }}>Searching…</p>
|
|
||||||
) : searchResults.length > 0 ? (
|
|
||||||
<div style={{ display: 'grid', gap: 'var(--space-1)' }}>
|
|
||||||
{searchResults.slice(0, 20).map(renderDistrictCard)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)' }}>No districts found matching "{query}".</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Back to suggestions */}
|
|
||||||
{searchResults.length > 0 && (
|
|
||||||
<button className="btn-outline" onClick={() => setQuery('')} style={{ marginTop: 'var(--space-2)' }}>
|
|
||||||
← Show suggested districts again
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hint */}
|
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-4)' }}>
|
|
||||||
Tip: Try districts like Derby (KS), Paterson (NJ), or Mobile County (AL) — they have notable arrest rates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateName(code) {
|
|
||||||
const names = {
|
|
||||||
AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming'
|
|
||||||
}
|
|
||||||
return names[code] || code
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Footer — matches the Civilytics visual style from theme/civilytics.scss.
|
|
||||||
*/
|
|
||||||
export default function Footer() {
|
|
||||||
return (
|
|
||||||
<footer className="cv-footer cv-wrap" style={{ marginTop: 'var(--space-5)' }}>
|
|
||||||
<span>© Civilytics — social science for the public good.</span>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
|
||||||
<a href="https://civilytics.com">civilytics.com</a>
|
|
||||||
<a href="https://github.com/civilytics/crdc-arrests" target="_blank" rel="noopener noreferrer">Source code</a>
|
|
||||||
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">API docs</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Methodology footnote */}
|
|
||||||
<div style={{ width: '100%', marginTop: 'var(--space-2)', padding: 'var(--space-1) 0', borderTop: '1px solid var(--cv-rule)' }}>
|
|
||||||
<span className="stat-label">About this demo</span>
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-0.5)' }}>
|
|
||||||
This application demonstrates the CRDC School Arrest Rate API, which provides Bayesian small-area estimates of school-based arrest rates from the US Department of Education Civil Rights Data Collection. Estimates use hierarchical models that improve precision for rare events — enabling meaningful comparisons across districts, student groups, and time. Cite: Knowles & Miller 2025.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import ChartPanel from './ChartPanel.jsx'
|
|
||||||
import * as api from '../hooks/useApi.js'
|
|
||||||
import { STUDENT_GROUPS, MODEL_QUADRANTS } from '../hooks/useApi.js'
|
|
||||||
import { shortGroupLabel } from '../utils/colors.js'
|
|
||||||
|
|
||||||
// CRDC waves to fetch (for Chart 1 — time series)
|
|
||||||
const WAVES = ['21-22', '17-18', '15-16']
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loading screen with animated histogram grid.
|
|
||||||
* Fetches all required data in parallel, shows progress as bars fill.
|
|
||||||
* When complete → renders ChartPanel directly (no transition needed).
|
|
||||||
*/
|
|
||||||
export default function LoadingAnimation({ district, state }) {
|
|
||||||
const [loadedCount, setLoadedCount] = useState(0)
|
|
||||||
const [totalCalls, setTotalCalls] = useState(0)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
// Build the full list of API calls needed for all 3 charts
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
// ——— Chart 1: Arrests over time (3 waves × default model) ———
|
|
||||||
const wavePromises = WAVES.map((year) =>
|
|
||||||
fetchDistrictEstimatesBatch(district.leaid, year)
|
|
||||||
.then(() => !cancelled && setLoadedCount(c => c + 1))
|
|
||||||
.catch(() => { /* individual failure doesn't block */ })
|
|
||||||
)
|
|
||||||
|
|
||||||
// ——— Chart 2: Rate by group (8 groups × default model, most recent wave) ———
|
|
||||||
const groupPromises = STUDENT_GROUPS.map((sg) =>
|
|
||||||
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex)
|
|
||||||
.then(() => !cancelled && setLoadedCount(c => c + 1))
|
|
||||||
.catch(() => {})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ——— Chart 3: posterior density, all 4 quadrant models so the dropdown can switch (8 groups × 4 = 32 calls) ———
|
|
||||||
const modelPromises = MODEL_QUADRANTS.flatMap((quad) =>
|
|
||||||
STUDENT_GROUPS.map((sg) =>
|
|
||||||
fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model)
|
|
||||||
.then(() => !cancelled && setLoadedCount(c => c + 1))
|
|
||||||
.catch(() => {})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set total before starting (for progress bar)
|
|
||||||
const allPromises = [...wavePromises, ...groupPromises, ...modelPromises]
|
|
||||||
if (!cancelled) setTotalCalls(allPromises.length)
|
|
||||||
|
|
||||||
await Promise.all(allPromises)
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
// All data loaded — ChartPanel renders in place of this component
|
|
||||||
// We use a render prop pattern: return <ChartPanel /> when done
|
|
||||||
setLoadedCount(allPromises.length)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) setError(err.message || 'Failed to load data')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadData()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [district.leaid])
|
|
||||||
|
|
||||||
// ——— Render the histogram grid animation or ChartPanel when done ———
|
|
||||||
const isComplete = loadedCount >= totalCalls && totalCalls > 0
|
|
||||||
|
|
||||||
if (isComplete) {
|
|
||||||
return <ChartPanel district={district} state={state} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return renderError(error, district)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the grid of "bars" — one per API call needed.
|
|
||||||
// Total = WAVES (3) + STUDENT_GROUPS (8) + MODEL_QUADRANTS×STUDENT_GROUPS (4×8=32)
|
|
||||||
const TOTAL_BARS = WAVES.length + STUDENT_GROUPS.length + (MODEL_QUADRANTS.length * STUDENT_GROUPS.length)
|
|
||||||
|
|
||||||
// Build bar metadata for rendering
|
|
||||||
const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => {
|
|
||||||
if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' }
|
|
||||||
if (i < WAVES.length + STUDENT_GROUPS.length) {
|
|
||||||
const sg = STUDENT_GROUPS[i - WAVES.length]
|
|
||||||
return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'groups' }
|
|
||||||
}
|
|
||||||
const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length)
|
|
||||||
const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length
|
|
||||||
const sg = STUDENT_GROUPS[sgIdx]
|
|
||||||
return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'models', modelIdx: quadIdx }
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
|
|
||||||
{/* Header */}
|
|
||||||
<span className="eyebrow">Fetching data from the CRDC Arrest Rate API</span>
|
|
||||||
<h2 style={{ marginTop: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
|
|
||||||
Building estimates for {district.lea_name} in {state === 'DC' ? "District of Columbia" : getStateName(state)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Progress counter */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-3)' }}>
|
|
||||||
<span style={{ color: 'var(--cv-navy-600)', fontWeight: 600, fontSize: '1.1rem' }}>
|
|
||||||
{loadedCount} of {totalCalls || allBars.length} datasets loaded
|
|
||||||
</span>
|
|
||||||
<span className="stat-label">
|
|
||||||
{Math.round((loadedCount / (totalCalls || allBars.length)) * 100)}% complete
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Animated histogram grid — mirrors the Bayesian posterior draw theme */}
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: `repeat(${3}, 1fr)`,
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
marginTop: 'var(--space-4)'
|
|
||||||
}}>
|
|
||||||
{/* Column headers */}
|
|
||||||
{['Time series (Chart 1)', 'By student group (Chart 2)', 'Posterior density (Chart 3)'].map((h, i) => (
|
|
||||||
<div key={i} style={{ textAlign: 'center', paddingBottom: 'var(--space-1)' }}>
|
|
||||||
<span className="stat-label" style={{ display: 'block' }}>{h}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Grid of animated bars — each represents one API call */}
|
|
||||||
{allBars.map((bar, i) => {
|
|
||||||
const filled = i < loadedCount
|
|
||||||
// Random height for "data viz" aesthetic (deterministic via seed = index)
|
|
||||||
const heightSeed = ((i * 37) % 100) + 20 // 20–120px range
|
|
||||||
return (
|
|
||||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
{/* The animated bar */}
|
|
||||||
<div style={{
|
|
||||||
width: '100%', height: `${heightSeed}px`,
|
|
||||||
background: filled
|
|
||||||
? `var(--cv-navy-${600 - (i % 2) * 100})`
|
|
||||||
: 'var(--paper-3)',
|
|
||||||
border: '1px solid var(--cv-rule)',
|
|
||||||
borderRadius: '4px 4px 0 0',
|
|
||||||
transition: 'height 300ms ease, background 300ms ease',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{filled && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', bottom: 0, left: 0, right: 0, height: '100%',
|
|
||||||
background: `linear-gradient(to top, var(--teal-600) 0%, transparent ${70 + (i % 30)}%)`,
|
|
||||||
opacity: 0.4, transition: 'opacity 500ms ease'
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Label below */}
|
|
||||||
<span style={{ fontSize: '0.65rem', color: filled ? 'var(--cv-ink)' : 'var(--cv-ink-4)', marginTop: '0.25rem', textAlign: 'center' }}>
|
|
||||||
{bar.subLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Context message */}
|
|
||||||
<p style={{ fontSize: '0.9rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-4)' }}>
|
|
||||||
Fetching {totalCalls || allBars.length} datasets across 3 CRDC waves, 8 student groups, and 4 Bayesian model specifications...
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Model legend */}
|
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-2)', marginTop: 'var(--space-3)', flexWrap: 'wrap' }}>
|
|
||||||
{MODEL_QUADRANTS.map((q, i) => (
|
|
||||||
<span key={i} className="stat-label" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
||||||
<span style={{ width: 12, height: 12, background: `var(--cv-navy-${600 - i * 150})`, borderRadius: 2 }} />
|
|
||||||
{q.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ——— API helper using the centralized client from useApi.js ———
|
|
||||||
// This ensures consistent error handling, envelope unwrapping, and CORS proxy support.
|
|
||||||
async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') {
|
|
||||||
return api.fetchDistrictEstimates(leaid, { year, model, race, sex })
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateName(code) {
|
|
||||||
const names = {
|
|
||||||
AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',CT:'Connecticut',DE:'Delaware',DC:'District of Columbia',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming'
|
|
||||||
}
|
|
||||||
return names[code] || code
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderError(error, district) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)', textAlign: 'center' }}>
|
|
||||||
<span className="stat-label" style={{ color: 'var(--cv-danger)' }}>ERROR</span>
|
|
||||||
<h3 style={{ marginTop: 'var(--space-2)' }}>Failed to load data for {district.lea_name}</h3>
|
|
||||||
<p style={{ color: 'var(--cv-ink-2)', maxWidth: '40rem', margin: '0 auto var(--space-3)' }}>
|
|
||||||
{error}. This may be a temporary API issue or the district may not have available estimates.
|
|
||||||
</p>
|
|
||||||
<button className="btn-outline" onClick={() => window.location.href = '/'}>
|
|
||||||
← Try another district
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
// All 50 states + DC, matching the API's ALLOWED_STATES
|
|
||||||
const STATES = [
|
|
||||||
'AL','AK','AZ','AR','CA','CO','CT','DE','DC','FL','GA','HI',
|
|
||||||
'ID','IL','IN','IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT',
|
|
||||||
'NE','NV','NH','NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD',
|
|
||||||
'TN','TX','UT','VT','VA','WA','WV','WI','WY'
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATE_NAMES = {
|
|
||||||
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
|
||||||
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', DC: 'District of Columbia', FL: 'Florida',
|
|
||||||
GA: 'Georgia', HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
|
||||||
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts',
|
|
||||||
MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri', MT: 'Montana',
|
|
||||||
NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey', NM: 'New Mexico',
|
|
||||||
NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma',
|
|
||||||
OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota',
|
|
||||||
TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia',
|
|
||||||
WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Landing screen — prompts user to select a state.
|
|
||||||
* Matches the Civilytics visual style with eyebrow label, stat callout, and clean card layout.
|
|
||||||
*/
|
|
||||||
export default function StateSelector({ onNext }) {
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
|
|
||||||
// Filter states by code or name as user types
|
|
||||||
const filtered = STATES.filter(
|
|
||||||
(code) =>
|
|
||||||
code.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
STATE_NAMES[code].toLowerCase().includes(query.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
|
|
||||||
{/* Eyebrow */}
|
|
||||||
<span className="eyebrow">US Department of Education Civil Rights Data Collection</span>
|
|
||||||
|
|
||||||
{/* Hero */}
|
|
||||||
<h1 style={{ marginTop: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
|
|
||||||
Explore school-based arrest rates by district
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p style={{ fontSize: '1.1rem', lineHeight: 1.7, maxWidth: '48rem' }}>
|
|
||||||
The Civil Rights Data Collection (CRDC) is the only source of data on school-related
|
|
||||||
arrests for all U.S. public schools and districts. Enter a state to see estimates for
|
|
||||||
any district — with Bayesian model comparisons that reveal how confident we can be in
|
|
||||||
each rate, even when arrests are rare events.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Stat callout — key context */}
|
|
||||||
<div className="stat-callout" style={{ marginTop: 'var(--space-4)' }}>
|
|
||||||
<div>
|
|
||||||
<span className="stat-num">34,846</span>
|
|
||||||
<span className="stat-label">students arrested in 2021–22</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="stat-num">0.72</span>
|
|
||||||
<span className="stat-label accent">arrests per 1,000 students (national rate)</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="stat-num">11.6%</span>
|
|
||||||
<span className="stat-label">of districts reported >0 arrests</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search input */}
|
|
||||||
<div style={{ marginTop: 'var(--space-5)' }}>
|
|
||||||
<label htmlFor="state-search" style={{ display: 'block', marginBottom: 'var(--space-2)', fontWeight: 600 }}>
|
|
||||||
Select a state to begin:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="state-search"
|
|
||||||
type="text"
|
|
||||||
placeholder="Type a state name or code (e.g., Texas, TX)…"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
|
|
||||||
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
|
|
||||||
fontFamily: 'var(--font-sans)'
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Filtered grid of states */}
|
|
||||||
{filtered.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
marginTop: 'var(--space-3)'
|
|
||||||
}}>
|
|
||||||
{filtered.map((code) => (
|
|
||||||
<button
|
|
||||||
key={code}
|
|
||||||
className="cv-card"
|
|
||||||
style={{ textAlign: 'center', padding: 'var(--space-2)' }}
|
|
||||||
onClick={() => onNext(code)}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--cv-navy-600)' }}>
|
|
||||||
{code}
|
|
||||||
</span>
|
|
||||||
<span className="stat-label" style={{ marginTop: '0.25rem' }}>
|
|
||||||
{STATE_NAMES[code]}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>
|
|
||||||
No states match "{query}". Try a full state name or two-letter code.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Context footer */}
|
|
||||||
<div style={{ marginTop: 'var(--space-5)', paddingBottom: 'var(--space-3)', borderBottom: '1px solid var(--cv-rule)' }}>
|
|
||||||
<span className="eyebrow">How it works</span>
|
|
||||||
<p style={{ fontSize: '0.9rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
|
|
||||||
After selecting a state, you'll search for a school district and see arrest estimates from 10 Bayesian models —
|
|
||||||
including one-year vs. three-year specifications and baseline vs. referral-rate covariate models.
|
|
||||||
The app fetches data directly from the{' '}
|
|
||||||
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">
|
|
||||||
CRDC Arrest Rate API
|
|
||||||
</a>. No account or key required.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}` : ''}`)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App.jsx'
|
|
||||||
import './styles/tokens.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
/* =============================================================
|
|
||||||
Civilytics Design Tokens — single source of truth.
|
|
||||||
Mirrored from crdc-arrests/theme/_tokens.scss so this demo app
|
|
||||||
stays in sync with the white paper / social media visual style.
|
|
||||||
|
|
||||||
Neutrals: warm paper → civic ink
|
|
||||||
Brand: Civic Navy (primary) + Ember (accent)
|
|
||||||
VIZ: Supporting palette for data series
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* — Paper (backgrounds) — */
|
|
||||||
--cv-paper: #FAF7F2;
|
|
||||||
--cv-paper-2: #F2EDE4;
|
|
||||||
--cv-paper-3: #E6DFD1;
|
|
||||||
|
|
||||||
/* — Rule / borders — */
|
|
||||||
--cv-rule: #D6CEBD;
|
|
||||||
--cv-rule-strong: #B8AE97;
|
|
||||||
|
|
||||||
/* — Ink (text) — */
|
|
||||||
--cv-ink: #0E1A2B;
|
|
||||||
--cv-ink-2: #2B3A52;
|
|
||||||
--cv-ink-3: #5A6A82;
|
|
||||||
--cv-ink-4: #8C97AB;
|
|
||||||
|
|
||||||
/* — Civic Navy (primary brand) — */
|
|
||||||
--cv-navy-50: #EEF3FA;
|
|
||||||
--cv-navy-100: #DDE6F2;
|
|
||||||
--cv-navy-200: #B3C6E0;
|
|
||||||
--cv-navy-300: #7A9BCA;
|
|
||||||
--cv-navy-400: #4A74B0;
|
|
||||||
--cv-navy-500: #2E5590;
|
|
||||||
--cv-navy-600: #22406A; /* links, primary buttons */
|
|
||||||
--cv-navy-700: #1A2E4A; /* hover state */
|
|
||||||
|
|
||||||
/* — Ember (accent) — */
|
|
||||||
--cv-accent: #C25311; /* main accent color */
|
|
||||||
--cv-accent-text: #A04400;
|
|
||||||
--cv-accent-dark: #923D00;
|
|
||||||
--cv-accent-hover: #7A3600;
|
|
||||||
|
|
||||||
/* — Supporting data-viz palette (from _tokens.scss) — */
|
|
||||||
--teal-600: #1F6F70;
|
|
||||||
--plum-600: #6B3A5E;
|
|
||||||
--moss-600: #4A6B2F;
|
|
||||||
--brass-600: #B8751C;
|
|
||||||
|
|
||||||
/* — Semantic status colors — */
|
|
||||||
--cv-success: #4A6B2F; /* moss */
|
|
||||||
--cv-warning: #9A5F18;
|
|
||||||
--cv-danger: #A6271D;
|
|
||||||
|
|
||||||
/* — Font stacks (from _tokens.scss) — */
|
|
||||||
--font-display: 'Libre Franklin', 'Franklin Gothic', 'Inter', system-ui, sans-serif;
|
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
||||||
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
|
|
||||||
|
|
||||||
/* — Spacing scale (from _tokens.scss) — */
|
|
||||||
--space-1: 0.5rem;
|
|
||||||
--space-2: 1rem;
|
|
||||||
--space-3: 1.5rem;
|
|
||||||
--space-4: 2rem;
|
|
||||||
--space-5: 3rem;
|
|
||||||
--space-6: 4rem;
|
|
||||||
--space-7: 6rem;
|
|
||||||
|
|
||||||
/* — Border radius (from _tokens.scss) — */
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 6px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
|
|
||||||
/* — Race categorical palette (validated, see src/utils/colors.js) — */
|
|
||||||
--race-wh: #3D6FC4;
|
|
||||||
--race-bl: #C98A2A;
|
|
||||||
--race-hi: #9C3F86;
|
|
||||||
--race-am: #3D9A6B;
|
|
||||||
|
|
||||||
/* — Chart-specific colors — aliased onto the tokens charts actually use */
|
|
||||||
--chart-modeled: var(--cv-navy-600);
|
|
||||||
--chart-frequentist: var(--cv-ink-4);
|
|
||||||
--chart-observed: var(--cv-ink);
|
|
||||||
|
|
||||||
/* — Model quadrant colors — */
|
|
||||||
--model-one-year-baseline: var(--cv-navy-600);
|
|
||||||
--model-three-year-baseline: var(--teal-600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Base reset + typography (from crdc-arrests/theme/civilytics.scss)
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
html { font-size: 100%; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--cv-paper);
|
|
||||||
color: var(--cv-ink);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
line-height: 1.08;
|
|
||||||
color: var(--cv-ink);
|
|
||||||
text-wrap: pretty;
|
|
||||||
margin: 0 0 .4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: clamp(2.25rem, 4.2vw, 3.75rem); font-weight: 900; letter-spacing: -0.035em; line-height: 1.0; }
|
|
||||||
h2 { font-size: clamp(1.75rem, 2.8vw, 2.375rem); }
|
|
||||||
h3 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.022em; color: var(--cv-ink-2); }
|
|
||||||
|
|
||||||
p, li { line-height: 1.7; text-wrap: pretty; }
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--cv-navy-600);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
transition: color 120ms ease;
|
|
||||||
}
|
|
||||||
a:hover { color: var(--cv-navy-700); text-decoration-thickness: 2px; }
|
|
||||||
|
|
||||||
code, .mono { font-family: var(--font-mono); font-feature-settings: "tnum" 1, "zero" 1; }
|
|
||||||
|
|
||||||
:focus-visible { outline: 2px solid var(--cv-accent); outline-offset: 2px; }
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Layout helpers (from civilytics.scss)
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
.cv-wrap { max-width: 60rem; margin: 0 auto; padding: 0 var(--space-3); }
|
|
||||||
|
|
||||||
.cv-header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--space-3) 0; border-bottom: 1px solid var(--cv-rule);
|
|
||||||
}
|
|
||||||
.cv-header .cv-logo { height: 28px; display: block; }
|
|
||||||
|
|
||||||
/* Eyebrow / section label (from civilytics.scss + extras.css) */
|
|
||||||
.eyebrow, .cv-eyebrow {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
color: var(--cv-accent-text);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.eyebrow::before, .cv-eyebrow::before {
|
|
||||||
content: "";
|
|
||||||
display: inline-block;
|
|
||||||
width: 24px; height: 2px;
|
|
||||||
background: var(--cv-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat callout — hero numbers (from extras.css) */
|
|
||||||
.stat-callout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 2rem;
|
|
||||||
margin: 2rem 0 2.5rem;
|
|
||||||
padding: 1.5rem 0;
|
|
||||||
border-top: 3px double var(--cv-ink);
|
|
||||||
border-bottom: 1px solid var(--cv-rule);
|
|
||||||
}
|
|
||||||
.stat-callout .stat-num {
|
|
||||||
font-family: 'Source Serif 4', Georgia, serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--cv-ink);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.stat-callout .stat-label {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--cv-ink-3);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
.stat-callout .stat-label.accent { color: var(--cv-accent); }
|
|
||||||
|
|
||||||
/* Card component (used for district suggestions, model cards) */
|
|
||||||
.cv-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--cv-rule);
|
|
||||||
border-top: 3px solid var(--cv-accent);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
||||||
}
|
|
||||||
.cv-card:hover {
|
|
||||||
border-color: var(--cv-ink-3);
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button (primary) */
|
|
||||||
.btn-primary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
background: var(--cv-navy-600);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, transform 20ms ease;
|
|
||||||
}
|
|
||||||
.btn-primary:hover { background: var(--cv-navy-700); }
|
|
||||||
.btn-primary:active { transform: scale(0.98); }
|
|
||||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* Button (secondary / outline) */
|
|
||||||
.btn-outline {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--cv-navy-600);
|
|
||||||
border: 1px solid var(--cv-rule);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, color 120ms ease;
|
|
||||||
}
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: var(--cv-paper-2);
|
|
||||||
color: var(--cv-navy-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.cv-footer {
|
|
||||||
border-top: 1px solid var(--cv-rule);
|
|
||||||
padding: var(--space-4) 0;
|
|
||||||
color: var(--cv-ink-3);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 680px) { .cv-footer { flex-direction: column; text-align: center; } }
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* Fixed categorical race palette — validated with the dataviz skill's
|
|
||||||
* validate_palette.js (passes lightness band, chroma floor, CVD separation
|
|
||||||
* ΔE 12.8, normal-vision floor ΔE 26.4; gold's sub-3:1 contrast WARN is
|
|
||||||
* mitigated by always-visible direct labels/legend in every chart that uses
|
|
||||||
* it). Order is fixed (WH, BL, HI, AM) and must not be re-derived from array
|
|
||||||
* position — re-run the validator before changing any of these hex values:
|
|
||||||
* node scripts/validate_palette.js "#3D6FC4,#C98A2A,#9C3F86,#3D9A6B" --mode light
|
|
||||||
*
|
|
||||||
* Sex (F/M) is deliberately NOT a second hue — it's encoded by row/facet
|
|
||||||
* position in every chart. Observed-vs-modeled is deliberately NOT a second
|
|
||||||
* hue either — it's encoded by mark type (diamond vs. filled bar/density).
|
|
||||||
*/
|
|
||||||
export const RACE_COLORS = { WH: '#3D6FC4', BL: '#C98A2A', HI: '#9C3F86', AM: '#3D9A6B' }
|
|
||||||
|
|
||||||
export const RACE_LABELS = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'American Indian / Alaska Native' }
|
|
||||||
|
|
||||||
// Compact form for axis ticks and other space-constrained labels, where the
|
|
||||||
// full RACE_LABELS text would overflow its container and get clipped by the
|
|
||||||
// SVG viewBox.
|
|
||||||
export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
|
|
||||||
|
|
||||||
// Mark-type colors for the observed-vs-modeled convention. Used where a
|
|
||||||
// chart has no race facet of its own (aggregate totals).
|
|
||||||
export const OBSERVED_MARK_COLOR = 'var(--cv-ink)'
|
|
||||||
export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)'
|
|
||||||
// Fallback for an unrecognized race key — kept neutral rather than defaulting to a real race hue.
|
|
||||||
export const REFERENCE_GRAY = 'var(--cv-ink-4)'
|
|
||||||
|
|
||||||
export function raceColor(race) {
|
|
||||||
return RACE_COLORS[race] || REFERENCE_GRAY
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupLabel(race, sex) {
|
|
||||||
return `${RACE_LABELS[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shortGroupLabel(race, sex) {
|
|
||||||
return `${SHORT_RACE_LABEL[race] || race} ${sex}`
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* Approximates a distribution shape from summary statistics only
|
|
||||||
* (median + interval bounds) when raw posterior draws aren't available
|
|
||||||
* client-side. This is NOT the true posterior — always pair its use with
|
|
||||||
* DISTRIBUTION_APPROX_NOTE (rendered via <ApproxNote />).
|
|
||||||
*
|
|
||||||
* Arrest rates/counts are right-skewed, not normal, so a single symmetric
|
|
||||||
* normal (the old approach) systematically misrepresents the shape. This
|
|
||||||
* fits two normal halves — one on each side of the median, each sized to
|
|
||||||
* its own interval bound — and splices their CDFs at the median. Because
|
|
||||||
* each half's CDF independently spans [0, 0.5] or [0.5, 1], the join is
|
|
||||||
* exactly the median by construction (cdf(median) === 0.5 always), unlike
|
|
||||||
* the classical two-piece-normal parameterization, which biases the median
|
|
||||||
* away from the split point whenever the two sigmas differ. Do not "fix"
|
|
||||||
* this toward that textbook formula — losing median-exactness is the bug,
|
|
||||||
* not a missing feature.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SQRT_2PI = Math.sqrt(2 * Math.PI)
|
|
||||||
const MIN_ABS_SIGMA = 1e-3
|
|
||||||
|
|
||||||
function standardNormalPdf(z) {
|
|
||||||
return Math.exp(-0.5 * z * z) / SQRT_2PI
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abramowitz & Stegun 7.1.26 approximation of erf, ~1.5e-7 max error.
|
|
||||||
function erf(x) {
|
|
||||||
const sign = x < 0 ? -1 : 1
|
|
||||||
const ax = Math.abs(x)
|
|
||||||
const a1 = 0.254829592
|
|
||||||
const a2 = -0.284496736
|
|
||||||
const a3 = 1.421413741
|
|
||||||
const a4 = -1.453152027
|
|
||||||
const a5 = 1.061405429
|
|
||||||
const p = 0.3275911
|
|
||||||
const t = 1 / (1 + p * ax)
|
|
||||||
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-ax * ax)
|
|
||||||
return sign * y
|
|
||||||
}
|
|
||||||
|
|
||||||
function standardNormalCdf(z) {
|
|
||||||
return 0.5 * (1 + erf(z / Math.SQRT2))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peter Acklam's rational approximation of the inverse standard normal CDF
|
|
||||||
// (probit), relative error < 1.15e-9. Supports arbitrary intervalMass.
|
|
||||||
function probit(p) {
|
|
||||||
const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
|
|
||||||
const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01]
|
|
||||||
const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
|
|
||||||
const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00]
|
|
||||||
const pLow = 0.02425
|
|
||||||
const pHigh = 1 - pLow
|
|
||||||
|
|
||||||
if (p < pLow) {
|
|
||||||
const q = Math.sqrt(-2 * Math.log(p))
|
|
||||||
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
||||||
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
|
|
||||||
}
|
|
||||||
if (p <= pHigh) {
|
|
||||||
const q = p - 0.5
|
|
||||||
const r = q * q
|
|
||||||
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
|
|
||||||
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
|
|
||||||
}
|
|
||||||
const q = Math.sqrt(-2 * Math.log(1 - p))
|
|
||||||
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
|
||||||
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {{median:number, lower:number, upper:number, intervalMass?:number, floorAtZero?:boolean}} p
|
|
||||||
* intervalMass: fraction of probability covered by [lower, upper] — the
|
|
||||||
* API's rate/count interval bounds are a 90% interval, so default 0.90.
|
|
||||||
* @returns {{ median:number, sigmaLeft:number, sigmaRight:number, pdf:(x:number)=>number, cdf:(x:number)=>number }}
|
|
||||||
*/
|
|
||||||
export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.90, floorAtZero = true }) {
|
|
||||||
const z = probit((1 + intervalMass) / 2)
|
|
||||||
|
|
||||||
let sigmaLeft = (median - lower) / z
|
|
||||||
let sigmaRight = (upper - median) / z
|
|
||||||
|
|
||||||
if (!(sigmaLeft > 0)) sigmaLeft = Math.max(sigmaRight * 0.15, MIN_ABS_SIGMA)
|
|
||||||
if (!(sigmaRight > 0)) sigmaRight = Math.max(sigmaLeft * 0.15, MIN_ABS_SIGMA)
|
|
||||||
sigmaLeft = Math.max(sigmaLeft, MIN_ABS_SIGMA)
|
|
||||||
sigmaRight = Math.max(sigmaRight, MIN_ABS_SIGMA)
|
|
||||||
|
|
||||||
function pdf(x) {
|
|
||||||
if (floorAtZero && x < 0) return 0
|
|
||||||
const sigma = x < median ? sigmaLeft : sigmaRight
|
|
||||||
return standardNormalPdf((x - median) / sigma) / sigma
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdf(x) {
|
|
||||||
if (floorAtZero && x < 0) return 0
|
|
||||||
const sigma = x <= median ? sigmaLeft : sigmaRight
|
|
||||||
return standardNormalCdf((x - median) / sigma)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inverse cdf — exact round-trip of fitSkewedInterval's own construction (quantile(fit, 0.05) === lower, etc). */
|
|
||||||
function quantile(p) {
|
|
||||||
const z = probit(p)
|
|
||||||
return median + (p <= 0.5 ? sigmaLeft : sigmaRight) * z
|
|
||||||
}
|
|
||||||
|
|
||||||
return { median, sigmaLeft, sigmaRight, pdf, cdf, quantile }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** n evenly spaced {x,y} points of the fitted pdf, for drawing a smooth curve. */
|
|
||||||
export function densityCurve(fit, { min = 0, max, n = 60 } = {}) {
|
|
||||||
const hi = max ?? fit.median + fit.sigmaRight * 3.5
|
|
||||||
const lo = Math.min(min, fit.median - fit.sigmaLeft * 0.1)
|
|
||||||
const step = (hi - lo) / (n - 1)
|
|
||||||
const points = []
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const x = lo + step * i
|
|
||||||
points.push({ x, y: fit.pdf(x) })
|
|
||||||
}
|
|
||||||
return points
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DISTRIBUTION_APPROX_NOTE =
|
|
||||||
'Distribution shape estimated from interval bounds — not raw posterior draws.'
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
|
||||||
|
|
||||||
/** model id -> display label, derived from the single source of truth in useApi.js. */
|
|
||||||
export const MODEL_QUADRANT_LABEL = Object.fromEntries(
|
|
||||||
MODEL_QUADRANTS.map((q) => [q.model, q.label])
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* "Nice numbers" axis tick generator (Heckbert 1990) — produces clean,
|
|
||||||
* human-friendly tick values (0 / 50 / 100 / 150 rather than 0 / 37 / 74 /
|
|
||||||
* 111, an artifact of slicing frac*max into equal fractions) for any linear
|
|
||||||
* axis, from just the data's max value.
|
|
||||||
*/
|
|
||||||
function niceNumber(range, round) {
|
|
||||||
const exponent = Math.floor(Math.log10(range))
|
|
||||||
const fraction = range / 10 ** exponent
|
|
||||||
let niceFraction
|
|
||||||
if (round) {
|
|
||||||
if (fraction < 1.5) niceFraction = 1
|
|
||||||
else if (fraction < 3) niceFraction = 2
|
|
||||||
else if (fraction < 7) niceFraction = 5
|
|
||||||
else niceFraction = 10
|
|
||||||
} else {
|
|
||||||
if (fraction <= 1) niceFraction = 1
|
|
||||||
else if (fraction <= 2) niceFraction = 2
|
|
||||||
else if (fraction <= 5) niceFraction = 5
|
|
||||||
else niceFraction = 10
|
|
||||||
}
|
|
||||||
return niceFraction * 10 ** exponent
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} maxValue - the largest value the axis must cover
|
|
||||||
* @param {number} tickCount - approximate desired number of ticks
|
|
||||||
* @returns {{ ticks: number[], niceMax: number }}
|
|
||||||
*/
|
|
||||||
export function niceTicks(maxValue, tickCount = 5) {
|
|
||||||
if (!(maxValue > 0)) return { ticks: [0], niceMax: 1 }
|
|
||||||
const niceRange = niceNumber(maxValue, false)
|
|
||||||
const niceStep = niceNumber(niceRange / (tickCount - 1), true)
|
|
||||||
const niceMax = Math.ceil(maxValue / niceStep) * niceStep
|
|
||||||
const ticks = []
|
|
||||||
for (let v = 0; v <= niceMax + niceStep / 2; v += niceStep) {
|
|
||||||
ticks.push(Math.round(v * 1e6) / 1e6)
|
|
||||||
}
|
|
||||||
return { ticks, niceMax }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react-swc'
|
|
||||||
|
|
||||||
// The app is served at /crdc-demo/ on pages.civilytics.org.
|
|
||||||
// Set base to the subdirectory so asset paths resolve correctly.
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: { port: 5173 },
|
|
||||||
build: { outDir: 'dist' },
|
|
||||||
base: '/crdc-demo/', // Required for subdirectory deployment on git-pages
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user