Compare commits

..
1 Commits
Author SHA1 Message Date
Gitea Actions d6e7717940 Deploy from 654b42ca71 2026-08-23 00:29:53 +00:00
47 changed files with 4761 additions and 5266 deletions
-62
View File
@@ -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
-39
View File
@@ -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/
-8
View File
@@ -1,8 +0,0 @@
node_modules/
dist/
*.local
*.log
.DS_Store
.env
.vscode/
.idea/
-136
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
-143
View File
@@ -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.
-7
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
654b42ca71f3654156e9b7a5e669dfa3c2fff41f
-12
View File
@@ -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
View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

+2 -1
View File
@@ -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
View File
@@ -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;
}
}
-2348
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -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"
}
-57
View File
@@ -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
View File
@@ -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>
)
}
-142
View File
@@ -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>
)
}
-133
View File
@@ -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>
)
}
-173
View File
@@ -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>
)
}
-10
View File
@@ -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>
)
}
-43
View File
@@ -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 }} />
}
-124
View File
@@ -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>
)
}
-175
View File
@@ -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
}
-24
View File
@@ -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>
)
}
-209
View File
@@ -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>
)
}
-137
View File
@@ -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 &gt;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>
)
}
-148
View File
@@ -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}` : ''}`)
}
-10
View File
@@ -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>,
)
-272
View File
@@ -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; } }
-40
View File
@@ -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}`
}
-123
View File
@@ -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.'
-6
View File
@@ -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])
)
-40
View File
@@ -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 }
}
-11
View File
@@ -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
})