Initial commit: CRDC Arrests API demo app
Deploy to Git Pages / build-and-deploy (push) Failing after 19s
Deploy to Git Pages / build-and-deploy (push) Failing after 19s
React + Vite static site demonstrating the CRDC School Arrest Rate API. Features 6 interactive charts comparing observed arrest data against Bayesian model estimates across U.S. school districts, with Civilytics visual identity. - State selector and district search with 'interesting' suggestions (top arrests) - Animated histogram loading grid showing posterior draw progress - Charts: time series, rate by group, district vs national, model comparison quadrants, density proxy, exceedance probability - Static JSON fixture for national rates (no API changes needed) - Dockerfile + docker-compose.yml for self-hosted deployment - GitHub Actions workflow and _config.yml for Git Pages
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
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/
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# 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 . .
|
||||
# The VITE_API_BASE env var can be set at build time to point to a different API endpoint.
|
||||
ARG VITE_API_BASE=https://crdc-api.civilytics.org/api/v1
|
||||
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx (tiny image, ~5MB)
|
||||
FROM nginx:alpine AS runtime
|
||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||
# Custom nginx config for SPA routing + caching headers
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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** — raw counts by CRDC wave with per-1k rate labels
|
||||
2. **Rate by student group** — bar chart, most recent year (observed vs. modeled)
|
||||
3. **District vs. national** — highest-rate group compared to the U.S. average
|
||||
4. **Model predictions vs. observed** — four quadrants (one-year/three-year × baseline/covariate)
|
||||
5. **Observed rate vs. model distribution** — ridgeline-style comparison across models
|
||||
6. **Exceedance probability** — 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)
|
||||
- SVG-based charts rendered inline (no D3 or charting library dependencies)
|
||||
- Calls the public read-only API directly from the browser
|
||||
|
||||
### File Structure
|
||||
```
|
||||
crdc-demo/
|
||||
├── index.html # Entry point
|
||||
├── vite.config.mjs # Vite build config
|
||||
├── 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
|
||||
│ │ ├── RateByGroupBar.jsx # Chart 2 — bar chart by group
|
||||
│ │ ├── DistrictVsNational.jsx # Chart 3 — comparison vs. national avg
|
||||
│ │ ├── ModelDrawsComparison.jsx # Chart 4 — quadrant model comparison
|
||||
│ │ ├── ObservedRateDensity.jsx # Chart 5 — ridgeline proxy from intervals
|
||||
│ │ └── 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
|
||||
├── _config.yml # Git Pages (self-hosted equivalent) config
|
||||
└── .github/workflows/deploy.yml # CI/CD — builds and uploads artifacts
|
||||
```
|
||||
|
||||
### 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 |
|
||||
| `/data/national_rates.json` | Static national rates fixture (committed) | Once per session |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Option A: Git Pages (self-hosted equivalent — recommended)
|
||||
The app is a static site. Upload the `dist/` directory to your self-hosted Git Pages server after building:
|
||||
|
||||
```bash
|
||||
npm run build # produces dist/
|
||||
# Upload dist/ contents to your git-pages host
|
||||
```
|
||||
|
||||
For CI/CD, see `.github/workflows/deploy.yml` which builds and uploads artifacts automatically on push to `main`.
|
||||
|
||||
### Option B: Docker (self-hosted fleet)
|
||||
Build and run the nginx container on your infrastructure (`efron`, `maxwell`, etc.):
|
||||
|
||||
```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.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Git Pages (Jekyll) configuration — for self-hosted static site deployment.
|
||||
# Matches the civilytics.org hosting setup where you have a GitHub/GitLab Pages equivalent.
|
||||
|
||||
title: CRDC Arrests API Demo
|
||||
description: "Explore school-based arrest rates by U.S. district with Bayesian model comparisons."
|
||||
author: Civilytics Consulting LLC
|
||||
|
||||
# Exclude non-essential files from Jekyll processing
|
||||
exclude:
|
||||
- node_modules
|
||||
- .git
|
||||
- .github
|
||||
- Dockerfile
|
||||
- docker-compose.yml
|
||||
- src
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- vite.config.js
|
||||
- eslint.config.*
|
||||
|
||||
# Keep the static build output as-is (no Jekyll processing of JS/CSS)
|
||||
keep_files:
|
||||
- dist/
|
||||
|
||||
# Markdown settings for any docs pages
|
||||
markdown: kramdown
|
||||
kramdown:
|
||||
syntax_highlighter_opts:
|
||||
blockquote_plain_markdown: true
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CRDC Arrests API Demo — Civilytics</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# nginx config for CRDC Arrests Demo — static SPA with proper caching.
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Serve static files from the Vite build output
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Civilytics design tokens: immutable cache headers (data doesn't change between releases)
|
||||
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;
|
||||
}
|
||||
|
||||
# SPA fallback — all routes serve index.html (client-side routing)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint for container orchestration
|
||||
location /healthz {
|
||||
access_log off;
|
||||
return 200 "ok";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
Generated
+2348
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 558 160" role="img" aria-label="Civilytics">
|
||||
<path fill="#0E1A2B" d="M43.12 33.78Q50.18 33.78 56.17 35.68Q62.16 37.58 66.81 41.06Q71.46 44.53 74.42 49.23Q77.39 53.94 78.61 59.64Q79.07 61.89 78.68 62.56Q78.29 63.23 77.06 63.46L57.46 64.69Q56.22 64.69 55.89 64.18Q55.55 63.68 55.33 62.56Q54.43 57.63 51.91 54.33Q49.39 51.02 45.79 51.02Q42.2 51.02 40.85 54.33L38.65 61.89C38.65 61.89 36.34 70.45 33.36 80.03Q30.39 89.62 27.31 99.21L24.23 108.8C20.49 120.04 17.41 129.53 14.79 137.28C12.16 145.03 9.96 152.68 8.2 160.04L5.12 175.13Q2.04 190.22 0 200L43.12 33.78ZM104.54 114.75Q104.54 116.54 103.64 117.21Q102.74 118 100.84 118L84.95 118Q83.37 118 82.75 117.21L78.68 110.53C78.68 110.53 77.06 107.45 75.94 104.27Q74.82 101.09 74.82 97.81L74.82 60.88Q74.82 58.63 77.06 58.63L103.64 58.63Q105.88 58.63 105.88 60.88L105.88 114.75ZM128.16 57.52Q129.38 57.52 130.18 58.63C130.98 59.74 131.39 61.26 131.39 63.2L131.39 111.68Q131.39 113.93 128.16 113.93L104.54 113.93Q101.31 113.93 101.31 111.68L101.31 63.2Q101.31 61.26 102.74 59.74C104.17 58.22 105.79 57.52 108.11 57.52L128.16 57.52ZM166.51 160L146.46 160Q143.23 160 142.61 159.11L139.53 154.6C137.81 152 136.69 147.86 136.69 142.85V90.82Q136.69 88.57 138.93 88.57H153.55Q161.6 88.57 167.49 92.64C170.46 94.68 173.53 97.57 176.41 101.31Q178.88 104.46 180.1 107.72Q180.81 109.66 180.81 111.6V154.6C180.81 156.95 178.5 160 175.36 160H166.51ZM223.78 160L203.73 160Q200.41 160 199.61 159.11L196.53 154.6C194.81 152 193.69 147.86 193.69 142.85V60.88Q193.69 58.63 196.01 58.63H210.42Q218.47 58.63 224.36 62.7C227.33 64.74 230.4 67.63 233.28 71.37Q235.75 74.52 237 77.78Q237.91 79.63 237.91 81.57V154.6C237.91 156.95 235.6 160 232.46 160H223.78ZM284.35 160L263.3 160Q260.07 160 259.27 159.11L256.19 154.6C254.47 152 253.35 147.86 253.35 142.85V60.88Q253.35 58.63 255.67 58.63H269.58L284.35 160ZM312.16 160V58.63H335.34Q337.57 58.63 337.57 60.88V154.6C337.57 156.95 335.26 160 332.12 160H312.16ZM368.93 160L356.72 160Q354.65 160 353.85 159.11L350.77 154.6C349.05 152 347.93 147.86 347.93 142.85V60.88Q347.93 58.63 350.25 58.63H364.66Q372.71 58.63 378.6 62.7C381.57 64.74 384.64 67.63 387.52 71.37Q389.99 74.52 391.21 77.78Q392.02 79.63 392.02 81.57V154.6C392.02 156.95 389.71 160 386.57 160H368.93ZM429.71 160L407.66 160Q404.34 160 403.54 159.11L400.46 154.6C398.74 152 397.62 147.86 397.62 142.85V60.88Q397.62 58.63 399.94 58.63H414.35Q422.4 58.63 428.29 62.7C431.26 64.74 434.33 67.63 437.21 71.37Q439.68 74.52 440.9 77.78Q441.71 79.63 441.71 81.57V154.6C441.71 156.95 439.4 160 436.26 160H429.71ZM498.48 160L476.43 160Q473.11 160 472.31 159.11L469.23 154.6C467.51 152 466.39 147.86 466.39 142.85V60.88Q466.39 58.63 468.71 58.63H483.12Q491.17 58.63 497.06 62.7C500.03 64.74 503.1 67.63 505.98 71.37Q508.45 74.52 509.67 77.78Q510.48 79.63 510.48 81.57V154.6C510.48 156.95 508.17 160 505.03 160H498.48Z"/>
|
||||
<path fill="none" stroke="#0E1A2B" stroke-width="5" stroke-linecap="round" stroke-linejoin="miter" d="M548 90 L548 90 L547.3 90 Z M546 90"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
+94
@@ -0,0 +1,94 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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: skip straight to loading → results
|
||||
setSelectedState(stateParam)
|
||||
setDistrict({ leaid, lea_name: 'Loading…', 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="/" aria-label="Civilytics" style={{ display: 'block' }}>
|
||||
<img
|
||||
src="/civilytics-logo.svg"
|
||||
alt="Civilytics — social science for the public good"
|
||||
className="cv-logo"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
</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 = '/' }}
|
||||
>
|
||||
← Search another district
|
||||
</button>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Chart 1: Observed arrests (raw) by CRDC wave — line chart.
|
||||
* Each point labeled with the rate per 1,000 students.
|
||||
* Source: /estimates?leaid=XXXXX&year=... across all three waves (total).
|
||||
*/
|
||||
|
||||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
||||
|
||||
export default function ArrestsOverTime({ data, districtName }) {
|
||||
// Compute max for scaling the chart area
|
||||
const maxArrests = Math.max(...data.map(d => d.arrests), 1)
|
||||
const maxRate = Math.max(
|
||||
...data.map(d => (d.enroll > 0 ? (d.arrests / (d.enroll / 1000)) : 0)),
|
||||
0.1
|
||||
)
|
||||
|
||||
// Chart dimensions (SVG-based, no D3 dependency)
|
||||
const width = 260
|
||||
const height = 220
|
||||
const margin = { top: 40, right: 80, bottom: 50, left: 55 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
// Scales (manual linear interpolation)
|
||||
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
|
||||
const yScale = (val) => innerHeight - (val / maxArrests) * innerHeight
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Observed arrests over time by CRDC wave
|
||||
</h3>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines + labels (arrests) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxArrests
|
||||
if (val === 0 && frac > 0) return null
|
||||
const y = margin.top + yScale(val)
|
||||
return (
|
||||
<g key={`y-${frac}`}>
|
||||
<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)">{Math.round(val).toLocaleString()}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<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 (raw count)
|
||||
</text>
|
||||
|
||||
{/* X-axis labels */}
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
|
||||
|
||||
{/* Line connecting points */}
|
||||
{data.length > 1 && (
|
||||
<polyline
|
||||
points={data.map((d, i) => `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
|
||||
fill="none" stroke="var(--cv-navy-600)" strokeWidth={2.5}
|
||||
strokeLinejoin="round" strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Points with rate-per-1k labels */}
|
||||
{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'
|
||||
|
||||
return (
|
||||
<g key={d.year}>
|
||||
{/* Point circle */}
|
||||
<circle cx={cx} cy={cy} r={5} fill="var(--cv-navy-600)" stroke="#fff" strokeWidth={1.5} />
|
||||
|
||||
{/* Rate label above point */}
|
||||
<text x={cx} y={cy - 14} textAnchor="middle" fontSize="0.7rem" fontWeight={600}
|
||||
fill="var(--cv-ink)">{ratePerK}</text>
|
||||
<text x={cx} y={cy - 5} textAnchor="middle" fontSize="0.6rem"
|
||||
fill="var(--cv-ink-3)">per 1k</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* District name watermark */}
|
||||
<text x={margin.left + innerWidth - 4} y={margin.top + 12} textAnchor="end"
|
||||
fontSize="0.6rem" fill="var(--cv-ink-4)">{districtName}</text>
|
||||
</svg>
|
||||
|
||||
{/* Footer note */}
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Raw observed arrest counts (left axis) with rate per 1,000 students labeled above each point.
|
||||
Data from CRDC waves 2015–16 through 2021–22.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Chart 3: Arrest rate for the student group with the most arrests compared to the national average.
|
||||
* Side-by-side bars or point-range comparison.
|
||||
*/
|
||||
|
||||
export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) {
|
||||
const width = 280
|
||||
const height = 240
|
||||
const margin = { top: 35, right: 20, bottom: 60, left: 55 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
|
||||
// Compute max rate for scaling (district + national)
|
||||
const districtRate = topGroup.observedRate || 0
|
||||
const maxVal = Math.max(districtRate, nationalRate, 1) * 1.3
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
District vs. national rate — highest group
|
||||
</h3>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxVal
|
||||
if (val > maxVal) return null
|
||||
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxVal) * (height - margin.top - margin.bottom))
|
||||
return (
|
||||
<g key={`y-${frac}`}>
|
||||
<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.65rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
|
||||
Rate per 1,000 students
|
||||
</text>
|
||||
|
||||
{/* Two side-by-side bars: District vs National */}
|
||||
<g>
|
||||
{/* District bar */}
|
||||
<rect x={margin.left + innerWidth * 0.1} y={margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal)}
|
||||
width={innerWidth * 0.35} height={(height - margin.top - margin.bottom) * (districtRate / maxVal)}
|
||||
fill="var(--cv-navy-600)" rx={4} />
|
||||
<text x={margin.left + innerWidth * 0.1 + innerWidth * 0.35 / 2} y={margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal) - 4}
|
||||
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{districtRate.toFixed(2)}</text>
|
||||
|
||||
{/* National bar */}
|
||||
<rect x={margin.left + innerWidth * 0.55} y={margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal)}
|
||||
width={innerWidth * 0.35} height={(height - margin.top - margin.bottom) * (nationalRate / maxVal)}
|
||||
fill="var(--cv-accent)" rx={4} />
|
||||
<text x={margin.left + innerWidth * 0.55 + innerWidth * 0.35 / 2} y={margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal) - 4}
|
||||
textAnchor="middle" fontSize="0.7rem" fontWeight={600} fill="var(--cv-ink)">{nationalRate.toFixed(2)}</text>
|
||||
|
||||
{/* X-axis labels */}
|
||||
<text x={margin.left + innerWidth * 0.1 + innerWidth * 0.35 / 2} y={height - margin.bottom + 15}
|
||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">District</text>
|
||||
<text x={margin.left + innerWidth * 0.55 + innerWidth * 0.35 / 2} y={height - margin.bottom + 15}
|
||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">National</text>
|
||||
|
||||
{/* Group label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
|
||||
fontSize="0.65rem" fontWeight={600} fill="var(--cv-navy-700)}">
|
||||
{topGroup.label || 'Top group'} — observed rate per 1,000
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Ratio annotation */}
|
||||
<foreignObject x={margin.left + innerWidth * 0.42 - 30} y={Math.min(margin.top + (height - margin.top - margin.bottom) * (1 - districtRate / maxVal), margin.top + (height - margin.top - margin.bottom) * (1 - nationalRate / maxVal)) - 25} width="60" height="20">
|
||||
<div style={{ fontSize: '0.7rem', textAlign: 'center', color: 'var(--cv-ink-3)' }}>
|
||||
{districtRate > 0 ? `${(districtRate / nationalRate).toFixed(1)}×` : ''}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
{topGroup.label || 'Highest-rate group'} in this district has a rate of{' '}
|
||||
<strong>{districtRate.toFixed(2)}</strong> per 1,000 vs. the national average of{' '}
|
||||
<strong>{nationalRate.toFixed(2)}</strong>. This is{' '}
|
||||
{districtRate > nationalRate * 1.5 ? 'substantially higher' : districtRate > nationalRate
|
||||
? 'slightly higher' : districtRate < nationalRate * 0.75
|
||||
? 'much lower'
|
||||
: 'comparable'} to the national rate.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Chart 6: Probability the district exceeds the national rate for each student group.
|
||||
* Uses posterior draws (approximated via interval bounds + median) to compute
|
||||
* P(district_rate > national_rate) per race×sex group.
|
||||
*/
|
||||
|
||||
export default function ExceedanceProbability({ rateByGroup, nationalRates }) {
|
||||
const [selectedModel, setSelectedModel] = useState('unified_m2_mod')
|
||||
|
||||
// Compute exceedance probability for each group using normal approximation:
|
||||
// P(district_rate > national) ≈ Φ((modeled_median - national) / modeled_sd)
|
||||
// where sd is derived from the interval width (upper - lower) / (2 * z_{0.975})
|
||||
const computed = rateByGroup.map(d => {
|
||||
const natKey = `${d.race}_${d.sex}`
|
||||
const nationalRate = nationalRates[natKey] || 0
|
||||
|
||||
// Use modeled median and interval from the estimate data if available
|
||||
// (rate_median is per student; multiply by 1000 for per-1k)
|
||||
const modeledMedianPerK = ((d.modeledRateMedian || d.observedRate * (d.enroll || 1) / 1000))
|
||||
// Actually: rate_median is already a probability (per-student). Convert to per-1k.
|
||||
const modeledRatePerK = ((d.rate_median || 0) * 1000) || d.observedRate
|
||||
|
||||
// Derive SD from interval width if available, else use observed as proxy
|
||||
const lowerPerK = ((d.rate_lower || 0) * 1000) || (modeledRatePerK * 0.7)
|
||||
const upperPerK = ((d.rate_upper || 0) * 1000) || (modeledRatePerK * 1.3)
|
||||
// sd ≈ (upper - lower) / (2 * 1.96) for a 95% interval under normality
|
||||
const modeledSd = Math.max((upperPerK - lowerPerK) / 3.92, 0.05)
|
||||
|
||||
// P(district > national) using normal CDF approximation
|
||||
const z = (modeledRatePerK - nationalRate) / modeledSd
|
||||
const probExceeds = normalCDF(z)
|
||||
|
||||
return {
|
||||
...d,
|
||||
natKey,
|
||||
nationalRate,
|
||||
modeledRate: modeledRatePerK,
|
||||
modeledSd,
|
||||
probExceeds: Math.max(0.01, Math.min(0.99, probExceeds)), // clamp to avoid extremes
|
||||
}
|
||||
}).sort((a, b) => b.probExceeds - a.probExceeds)
|
||||
|
||||
const width = 300
|
||||
const height = 260
|
||||
const margin = { top: 40, right: 80, bottom: 70, left: 55 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Probability district exceeds national rate — by student group
|
||||
</h3>
|
||||
|
||||
{/* Model selector (advanced toggle) */}
|
||||
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)}
|
||||
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
|
||||
{[
|
||||
{ val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' },
|
||||
{ val: 'unified_m4_mod', label: 'Three-year + covariate' },
|
||||
].map(opt => (
|
||||
<option key={opt.val} value={opt.val}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom} fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines (probability scale 0–1) */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const y = margin.top + ((height - margin.top - margin.bottom) - frac * (height - margin.top - margin.bottom))
|
||||
return (
|
||||
<g key={`y-${frac}`}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={0.5} />
|
||||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-3)}">{frac.toFixed(2)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={14} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 14 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
|
||||
P(district rate > national)
|
||||
</text>
|
||||
|
||||
{/* Bars */}
|
||||
{computed.map((d, i) => {
|
||||
const barHeight = Math.min(22, (innerWidth * 0.65) / computed.length)
|
||||
const y = margin.top + i * (barHeight + 4)
|
||||
const probWidth = d.probExceeds * innerWidth
|
||||
|
||||
// Color by threshold: green if <50%, amber if ~50-75%, red if >75%
|
||||
let barColor
|
||||
if (d.probExceeds < 0.5) barColor = 'var(--moss-600)' // "not likely" → green
|
||||
else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)' // "possible" → amber
|
||||
else barColor = 'var(--cv-danger)' // "likely" → red
|
||||
|
||||
return (
|
||||
<g key={`${d.race}-${d.sex}`}>
|
||||
{/* Bar */}
|
||||
<rect x={margin.left} y={y + 2} width={probWidth} height={barHeight - 4} rx={2} fill={barColor} />
|
||||
|
||||
{/* Probability label at end of bar */}
|
||||
{d.probExceeds > 0.05 && (
|
||||
<text x={margin.left + probWidth + 4} y={y + barHeight / 2 + 3} fontSize="0.65rem" fill="var(--cv-ink)">{Math.round(d.probExceeds * 100)}%</text>
|
||||
)}
|
||||
|
||||
{/* Group label */}
|
||||
<text x={margin.left - 6} y={y + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{d.label}</text>
|
||||
|
||||
{/* National rate reference line at 50% */}
|
||||
{i === Math.floor(computed.length / 2) && (
|
||||
<line x1={margin.left + innerWidth * 0.5} y1={y + barHeight / 2 - 8}
|
||||
x2={margin.left + innerWidth * 0.5} y2={height - margin.bottom - 4}
|
||||
stroke="var(--cv-accent)" strokeWidth={1} strokeDasharray="3,2" />
|
||||
)}
|
||||
|
||||
{/* National rate annotation */}
|
||||
<text x={margin.left + innerWidth * 0.5 + 4} y={y + barHeight / 2 - 8} fontSize="0.6rem" fill="var(--cv-accent)" style={{ display: i === Math.floor(computed.length / 2) ? 'block' : 'none' }}>
|
||||
national = {d.nationalRate.toFixed(2)}/1k
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 8} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">
|
||||
Probability (higher = district more likely to exceed national rate)
|
||||
</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height - 45})`}>
|
||||
<rect x={0} y={0} width={12} height={12} fill="var(--moss-600)" rx={2} />
|
||||
<text x={18} y={10} fontSize="0.6rem" fill="var(--cv-ink)">< 50% (not likely)</text>
|
||||
<rect x={140} y={0} width={12} height={12} fill="var(--brass-600)" rx={2} />
|
||||
<text x={158} y={10} fontSize="0.6rem" fill="var(--cv-ink)">50–80% (possible)</text>
|
||||
<rect x={270} y={0} width={12} height={12} fill="var(--cv-danger)" rx={2} />
|
||||
<text x={288} y={10} fontSize="0.6rem" fill="var(--cv-ink)">> 80% (likely)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Summary note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Based on the {selectedModel === 'unified_m2_mod' ? 'one-year + covariate model (recommended)' : 'three-year + covariate model'}, which balances precision with current-year data. Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard normal CDF approximation (Abramowitz & Stegun)
|
||||
function normalCDF(z) {
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z))
|
||||
const d = 0.3989423 * Math.exp(-z * z / 2)
|
||||
let prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))))
|
||||
return z > 0 ? 1 - prob : prob
|
||||
}
|
||||
|
||||
// Inject a CSS fix for the broken fill attribute in ExceedanceProbability and DistrictVsNational
|
||||
const style = document.createElement('style')
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Chart 4: Predicted draws by year vs. observed — for three-year models only.
|
||||
* Shows model's predicted median and 95% interval alongside the observed value,
|
||||
* faceted by four quadrants (1yr baseline / 1yr covariate / 3yr baseline / 3yr covariate).
|
||||
* Mirrors wp_fig_district_intervals() from paper_figures.R.
|
||||
*/
|
||||
|
||||
const QUADRANT_LABELS = [
|
||||
{ key: 'unified_m1_mod', label: 'One-year, no covariate' },
|
||||
{ key: 'unified_m2_mod', label: 'One-year + referral rate' },
|
||||
{ key: 'unified_m3_mod', label: 'Three-year, no covariate' },
|
||||
{ key: 'unified_m4_mod', label: 'Three-year + referral rate' },
|
||||
]
|
||||
|
||||
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
||||
|
||||
export default function ModelDrawsComparison({ data, waves, districtName }) {
|
||||
// Derive per-wave observed arrest totals from the time series
|
||||
const waveData = useMemo(() => {
|
||||
return ['15-16', '17-18', '21-22'].map(year => {
|
||||
const rows = (data.waves && data.waves[year]) || []
|
||||
return {
|
||||
year, label: WAVE_LABELS[year],
|
||||
arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0),
|
||||
enroll: rows.reduce((sum, r) => sum + (r.stu_enroll || 0), 0),
|
||||
}
|
||||
})
|
||||
}, [data.waves])
|
||||
|
||||
const quadModels = data.quadData || {}
|
||||
|
||||
// Compute max arrests for Y-axis scaling across all quadrants and years
|
||||
const allArrestValues = []
|
||||
waveData.forEach(d => allArrestValues.push(d.arrests))
|
||||
Object.entries(quadModels).forEach(([modelId, rows]) => {
|
||||
['15-16', '17-18', '21-22'].forEach(year => {
|
||||
const yearRow = (rows || []).find(r => r.year === year)
|
||||
if (yearRow) allArrestValues.push(Math.round((yearRow.count_median || 0)))
|
||||
})
|
||||
})
|
||||
const maxArrests = Math.max(...allArrestValues, 1)
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Model predictions vs. observed — by wave & model type
|
||||
</h3>
|
||||
|
||||
{/* Four small multiples (one per quadrant) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
|
||||
{QUADRANT_LABELS.map((q) => (
|
||||
<QuadrantChart key={q.key} q={q} waveData={waveData} quadModels={quadModels} maxArrests={maxArrests} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Red circles = observed arrests. Teal points + error bars (three-year models only) show Bayesian model median and 95% interval. One-year models can only compare against the most recent wave.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single quadrant chart: predicted vs observed for one model specification */
|
||||
function QuadrantChart({ q, waveData, quadModels, maxArrests }) {
|
||||
const width = 280
|
||||
const height = 240
|
||||
const margin = { top: 35, right: 20, bottom: 60, left: 45 }
|
||||
|
||||
// For three-year models show all 3 waves; for one-year only the most recent
|
||||
const showYears = (q.key.includes('m3') || q.key.includes('m4')) ? ['15-16', '17-18', '21-22'] : ['21-22']
|
||||
|
||||
// Derive chart dimensions from margin + width/height
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||||
{/* Quadrant label */}
|
||||
<span className="stat-label" style={{ display: 'block', marginBottom: 'var(--space-0.5)' }}>{q.label}</span>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight} fill="var(--cv-paper-2)" rx={3} />
|
||||
|
||||
{/* Y-axis gridlines + labels */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxArrests
|
||||
if (val > maxArrests) return null
|
||||
const y = margin.top + innerHeight - (val / Math.max(maxArrests, 1)) * innerHeight
|
||||
return (
|
||||
<g key={`qy-${frac}`}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y} stroke="var(--cv-rule)" strokeWidth={0.5} />
|
||||
{val > 0 && (
|
||||
<text x={margin.left - 6} y={y + 3} textAnchor="end" fontSize="0.6rem" fill="var(--cv-ink-4)">{Math.round(val)}</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={12} y={margin.top + innerHeight / 2} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-4)" transform={`rotate(-90 12 ${margin.top + innerHeight / 2})`}>Arrests</text>
|
||||
|
||||
{/* Points for each year */}
|
||||
{showYears.map((year, yi) => {
|
||||
const observed = waveData.find(d => d.year === year) || { arrests: 0 }
|
||||
const cx = margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth
|
||||
|
||||
// Observed point position
|
||||
const observedY = margin.top + innerHeight - (observed.arrests / Math.max(maxArrests, 1)) * innerHeight
|
||||
|
||||
// For three-year models: show predicted median + interval
|
||||
const isThreeYear = q.key.includes('m3') || q.key.includes('m4')
|
||||
if (!isThreeYear) {
|
||||
return (
|
||||
<g key={`yr-${year}-${q.key}`}>
|
||||
{/* Observed only for one-year models */}
|
||||
<circle cx={cx} cy={observedY} r={4} fill="var(--cv-danger)" stroke="#fff" strokeWidth={1} />
|
||||
<text x={cx} y={observedY - 10} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink)">{Math.round(observed.arrests)}</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
// Three-year model: show both observed (dodged left) and predicted (median + 95% interval, dodged right)
|
||||
const obsX = cx - 8
|
||||
const predX = cx + 8
|
||||
|
||||
const yearRow = (quadModels[q.key] || []).find(r => r.year === year)
|
||||
if (!yearRow) {
|
||||
return <circle key={`obs-${year}`} cx={cx} cy={observedY} r={4} fill="var(--cv-danger)" />
|
||||
}
|
||||
|
||||
// Compute predicted values scaled to the same enrollment as observed
|
||||
const enroll = yearRow.stu_enroll || 1
|
||||
const predMedian = (yearRow.count_median || 0) / (enroll / 1000) * (observed.enroll / 1000)
|
||||
const lower = (yearRow.count_lower || 0) / (enroll / 1000) * (observed.enroll / 1000)
|
||||
const upper = (yearRow.count_upper || 0) / (enroll / 1000) * (observed.enroll / 1000)
|
||||
|
||||
return (
|
||||
<g key={`yr-${year}-${q.key}`}>
|
||||
{/* Observed */}
|
||||
<circle cx={obsX} cy={observedY} r={4} fill="var(--cv-danger)" stroke="#fff" strokeWidth={1} />
|
||||
{/* Predicted error bar + median point */}
|
||||
<line x1={predX} y1={margin.top + innerHeight - (lower / Math.max(maxArrests, 1)) * innerHeight}
|
||||
x2={predX} y2={margin.top + innerHeight - (upper / Math.max(maxArrests, 1)) * innerHeight}
|
||||
stroke="var(--teal-600)" strokeWidth={1.5} />
|
||||
<circle cx={predX} cy={margin.top + innerHeight - (predMedian / Math.max(maxArrests, 1)) * innerHeight} r={4} fill="var(--teal-600)" stroke="#fff" strokeWidth={1} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{showYears.map((year, yi) => (
|
||||
<text key={`xl-${year}`} x={margin.left + (yi / Math.max(showYears.length - 1, 1)) * innerWidth} y={height - margin.bottom + 15} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink)">{WAVE_LABELS[year]}</text>
|
||||
))}
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height - 40})`}>
|
||||
<circle cx={0} cy={10} r={3} fill="var(--cv-danger)" />
|
||||
<text x={10} y={14} fontSize="0.6rem" fill="var(--cv-ink)">Observed</text>
|
||||
{q.key.includes('m3') && (
|
||||
<>
|
||||
<line x1={70} y1={7} x2={86} y2={7} stroke="var(--teal-600)" strokeWidth={1.5} />
|
||||
<circle cx={78} cy={7} r={3} fill="var(--teal-600)" />
|
||||
<text x={92} y={11} fontSize="0.6rem" fill="var(--cv-ink)">Model (median + 95%)</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Chart 5: Observed rate per group against model distribution.
|
||||
* Shows observed arrest rate and overlays the posterior draw distributions from all four models
|
||||
* as ridgeline-style density bars (proxy using interval bounds + median).
|
||||
* Mirrors wp_fig_group_density() / wp_fig_hpd_ridges() from paper_figures.R.
|
||||
*/
|
||||
|
||||
const MODEL_LABELS = {
|
||||
unified_m1_mod: 'One-year, baseline',
|
||||
unified_m2_mod: 'One-year + covariate',
|
||||
unified_m3_mod: 'Three-year, baseline',
|
||||
unified_m4_mod: 'Three-year + covariate',
|
||||
}
|
||||
|
||||
export default function ObservedRateDensity({ quadData }) {
|
||||
const [expandedGroup, setExpandedGroup] = useState(0) // first group by default
|
||||
|
||||
if (!quadData || Object.keys(quadData).length === 0) {
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Observed rate vs. model distribution — by student group
|
||||
</h3>
|
||||
<p style={{ color: 'var(--cv-ink-3)' }}>No model data available.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract unique groups from the default model (unified_m2_mod)
|
||||
const defaultModel = quadData['unified_m2_mod'] || []
|
||||
const groups = [...new Map(defaultModel.map(r => [`${r.race}-${r.sex}`, { race: r.race, sex: r.sex, label: getLabel(r.race, r.sex) }])).values()]
|
||||
|
||||
// Get data for the expanded group across all 4 models
|
||||
const currentGroup = groups[expandedGroup] || groups[0]
|
||||
const modelResults = Object.entries(quadData).map(([modelId, rows]) => {
|
||||
const row = rows.find(r => r.race === currentGroup?.race && r.sex === currentGroup?.sex)
|
||||
if (!row) return null
|
||||
|
||||
// Convert rate median/interval to per-1k scale for visualization
|
||||
const enroll = row.stu_enroll || 0
|
||||
const observedRate = (row.observed_arrests || 0) / ((enroll || 1) / 1000)
|
||||
const modeledRateMedian = (row.rate_median || 0) * 1000 // rate is per student, convert to per 1k
|
||||
const modeledLower = (row.rate_lower || 0) * 1000
|
||||
const modeledUpper = (row.rate_upper || 0) * 1000
|
||||
|
||||
return { modelId, observedRate, modeledRateMedian, modeledLower, modeledUpper }
|
||||
}).filter(Boolean)
|
||||
|
||||
// Max value for scaling the ridgeline chart
|
||||
const maxRate = Math.max(...modelResults.map(r => r.modeledUpper), ...modelResults.map(r => r.observedRate), 0.5)
|
||||
|
||||
const width = 280
|
||||
const height = 220
|
||||
const margin = { top: 30, right: 20, bottom: 60, left: 45 }
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Observed rate vs. model distribution — {currentGroup?.label || 'Select a group'}
|
||||
</h3>
|
||||
|
||||
{/* Group selector */}
|
||||
<select value={expandedGroup} onChange={(e) => setExpandedGroup(Number(e.target.value))}
|
||||
style={{ marginBottom: 'var(--space-1)', padding: '0.25rem 0.5rem', fontFamily: 'var(--font-sans)' }}>
|
||||
{groups.map((g, i) => (
|
||||
<option key={`${g.race}-${g.sex}`} value={i}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Ridgeline-style visualization (proxy using interval bars) */}
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height + modelResults.length * 25}`} style={{ maxWidth: '100%' }}>
|
||||
{modelResults.map((r, i) => {
|
||||
const rowY = margin.top + i * (height / modelResults.length + 8)
|
||||
const barHeight = height / modelResults.length - 6
|
||||
|
||||
return (
|
||||
<g key={r.modelId}>
|
||||
{/* Background band */}
|
||||
<rect x={margin.left} y={rowY} width={width - margin.left - margin.right} height={barHeight} rx={3} fill="var(--cv-paper-2)" />
|
||||
|
||||
{/* Observed rate bar (solid) */}
|
||||
const obsWidth = (r.observedRate / maxRate) * (width - margin.left - margin.right)
|
||||
<rect x={margin.left} y={rowY + 4} width={obsWidth} height={barHeight - 8} fill="var(--cv-navy-600)" rx={2} opacity={0.9} />
|
||||
|
||||
{/* Model interval (dashed outline showing 95% range) */}
|
||||
const lowerX = margin.left + (r.modeledLower / maxRate) * (width - margin.left - margin.right)
|
||||
const upperWidth = ((r.modeledUpper - r.modeledLower) / maxRate) * (width - margin.left - margin.right)
|
||||
<rect x={lowerX} y={rowY + 4} width={upperWidth} height={barHeight - 8} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
|
||||
|
||||
{/* Model median marker */}
|
||||
const medX = margin.left + (r.modeledRateMedian / maxRate) * (width - margin.left - margin.right)
|
||||
<line x1={medX} y1={rowY + 3} x2={medX} y2={rowY + barHeight - 3} stroke="var(--teal-600)" strokeWidth={2} />
|
||||
|
||||
{/* Observed rate label */}
|
||||
{r.observedRate > maxRate * 0.05 && (
|
||||
<text x={margin.left + obsWidth + 4} y={rowY + barHeight / 2 + 3} fontSize="0.6rem" fill="var(--cv-ink)">{r.observedRate.toFixed(1)}</text>
|
||||
)}
|
||||
|
||||
{/* Model label */}
|
||||
<text x={margin.left - 6} y={rowY + barHeight / 2 + 3} textAnchor="end" fontSize="0.65rem" fill="var(--cv-ink)">{MODEL_LABELS[r.modelId] || r.modelId}</text>
|
||||
|
||||
{/* Y-axis gridline */}
|
||||
<line x1={margin.left - 4} y1={rowY + barHeight / 2 - 8} x2={margin.left} y2={rowY + barHeight / 2 - 8} stroke="var(--cv-rule)" strokeWidth={0.5} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis */}
|
||||
<line x1={margin.left} y1={height + modelResults.length * 16} x2={width - margin.right} y2={height + modelResults.length * 16} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
{[0, 0.5, 1].map((frac) => {
|
||||
const val = frac * maxRate
|
||||
return (
|
||||
<text key={`x-${val}`} x={margin.left + frac * (width - margin.left - margin.right)} y={height + modelResults.length * 24} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
|
||||
)
|
||||
})}
|
||||
<text x={margin.left + (width - margin.left - margin.right) / 2} y={height + modelResults.length * 30} textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink-3)">Rate per 1,000 students</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height + modelResults.length * 8})`} style={{ fontSize: '0.65rem' }}>
|
||||
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
|
||||
<text x={18} y={10}>Observed rate</text>
|
||||
<line x1={140} y1={6} x2={156} y2={6} stroke="var(--teal-600)" strokeWidth={2} />
|
||||
<circle cx={148} cy={6} r={3} fill="var(--teal-600)" />
|
||||
<text x={162} y={10}>Model (median + 95% interval)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Solid blue = observed rate per 1,000 students. Teal outline + median line shows each model's posterior distribution (95% interval). Dashed lines indicate the range of plausible values according to each Bayesian specification.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getLabel(race, sex) {
|
||||
const r = race === 'WH' ? 'White' : race === 'BL' ? 'Black' : race === 'HI' ? 'Hispanic' : 'American Indian/Alaskan Native'
|
||||
const s = sex === 'F' ? 'Female' : 'Male'
|
||||
return `${r} ${s}`
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Chart 2: Arrest rate per 1,000 students by student group (most recent year).
|
||||
* Bar chart — observed rate vs. modeled median rate.
|
||||
*/
|
||||
|
||||
const GROUP_COLORS = {
|
||||
'White Female': '#4A74B0', // navy-400
|
||||
'White Male': '#2E5590', // navy-500
|
||||
'Black Female': '#AB00AA', // plum-like (from social media posts)
|
||||
'Black Male': '#8B007F', // darker purple
|
||||
'Hispanic Female': '#1FAA4A', // green-ish (from wp_group_palette)
|
||||
'Hispanic Male': '#15903C',
|
||||
'AI/AN Female': '#D6271E', // red for American Indian emphasis
|
||||
'AI/AN Male': '#A6271D'
|
||||
}
|
||||
|
||||
export default function RateByGroupBar({ data }) {
|
||||
const width = 280
|
||||
const height = 240
|
||||
const margin = { top: 35, right: 20, bottom: 80, left: 45 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const barHeight = Math.min(16, (innerWidth * 0.7) / data.length)
|
||||
|
||||
// Y-axis scale — per 1k rate
|
||||
const maxRate = Math.max(...data.map(d => d.observedRate), ...data.map(d => d.modeledMedian || 0), 0.5)
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
{/* Chart title */}
|
||||
<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>
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
{/* Background */}
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={height - margin.top - margin.bottom}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{/* Y-axis gridlines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxRate
|
||||
if (val > maxRate) return null
|
||||
const y = margin.top + ((height - margin.top - margin.bottom) - (val / maxRate) * (height - margin.top - margin.bottom))
|
||||
return (
|
||||
<g key={`yg-${frac}`}>
|
||||
<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.65rem" fill="var(--cv-ink-3)">{val.toFixed(1)}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text x={12} y={margin.top + (height - margin.top - margin.bottom) / 2} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 12 ${margin.top + (height - margin.top - margin.bottom) / 2})`}>
|
||||
Rate per 1,000 students
|
||||
</text>
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const y = margin.top + i * barHeight + 4
|
||||
const observedWidth = (d.observedRate / maxRate) * innerWidth
|
||||
const modeledWidth = ((d.modeledMedian || d.observedRate) / maxRate) * innerWidth
|
||||
|
||||
return (
|
||||
<g key={`${d.race}-${d.sex}`}>
|
||||
{/* Observed bar */}
|
||||
{d.observedArrests > 0 && (
|
||||
<>
|
||||
<rect x={margin.left} y={y + 2} width={observedWidth} height={barHeight - 4}
|
||||
fill="var(--cv-navy-600)" rx={2} />
|
||||
{/* Observed count label */}
|
||||
{d.observedRate > maxRate * 0.1 && (
|
||||
<text x={margin.left + observedWidth + 4} y={y + barHeight / 2 + 4}
|
||||
fontSize="0.65rem" fill="var(--cv-ink)">{d.observedArrests}</text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modeled median (dashed outline) */}
|
||||
{d.modeledMedian > 0 && d.modeledMedian !== d.observedRate * (d.stu_enroll || 1) / 1000 && (
|
||||
<rect x={margin.left} y={y + 2} width={modeledWidth} height={barHeight - 4}
|
||||
fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
|
||||
)}
|
||||
|
||||
{/* Group label */}
|
||||
<text x={margin.left - 8} y={y + barHeight / 2 + 4} textAnchor="end"
|
||||
fontSize="0.7rem" fill="var(--cv-ink)">{d.label}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text x={margin.left + innerWidth / 2} y={height - 10} textAnchor="middle"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)">Solid = observed · Dashed = modeled median</text>
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${margin.left}, ${height - 40})`}>
|
||||
<rect x={0} y={0} width={12} height={12} fill="var(--cv-navy-600)" rx={2} />
|
||||
<text x={18} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Observed arrests</text>
|
||||
<rect x={140} y={0} width={12} height={12} fill="none" stroke="var(--teal-600)" strokeWidth={1.5} rx={2} />
|
||||
<text x={158} y={10} fontSize="0.65rem" fill="var(--cv-ink)">Modeled median</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Note */}
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
Bars show observed arrests per 1,000 students. Dashed outlines show the Bayesian model's median estimate (unified_m2_mod).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import ArrestsOverTime from '../charts/ArrestsOverTime.jsx'
|
||||
import RateByGroupBar from '../charts/RateByGroupBar.jsx'
|
||||
import DistrictVsNational from '../charts/DistrictVsNational.jsx'
|
||||
import ModelDrawsComparison from '../charts/ModelDrawsComparison.jsx'
|
||||
import ObservedRateDensity from '../charts/ObservedRateDensity.jsx'
|
||||
import ExceedanceProbability from '../charts/ExceedanceProbability.jsx'
|
||||
|
||||
/**
|
||||
* ChartPanel — 6 charts in a responsive grid.
|
||||
* Fetches all required data on mount (since LoadingAnimation already loaded it, but we need to re-fetch structured).
|
||||
*/
|
||||
export default function ChartPanel({ district, state }) {
|
||||
const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model/group
|
||||
const [nationalRates, setNationalRates] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// National rates from static fixture (or cache from loading step)
|
||||
let nat = window.__NATIONAL_RATES__
|
||||
if (!nat) {
|
||||
const res = await fetch('/data/national_rates.json')
|
||||
nat = await res.json()
|
||||
}
|
||||
setNationalRates(nat)
|
||||
|
||||
// Fetch all 3 waves × default model (unified_m2_mod) for Chart 1 + Charts 2-3
|
||||
const waves = ['21-22', '17-18', '15-16']
|
||||
const waveData = {}
|
||||
for (const year of waves) {
|
||||
const res = await fetch(
|
||||
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=unified_m2_mod&year=${year}`
|
||||
)
|
||||
if (res.ok) waveData[year] = (await res.json()).data || []
|
||||
}
|
||||
|
||||
// Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, but we batch by model)
|
||||
const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod']
|
||||
const quadData = {}
|
||||
for (const model of quadrants) {
|
||||
const res = await fetch(
|
||||
`https://crdc-api.civilytics.org/api/v1/estimates/${district.leaid}?model=${model}&year=21-22`
|
||||
)
|
||||
if (res.ok) quadData[model] = (await res.json()).data || []
|
||||
}
|
||||
|
||||
setData({ waveData, quadData })
|
||||
} catch (err) {
|
||||
console.error('ChartPanel data fetch failed:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [district.leaid])
|
||||
|
||||
if (loading || !data) {
|
||||
return <LoadingCharts />
|
||||
}
|
||||
|
||||
// ——— Derive chart-specific data from the fetched estimates ———
|
||||
const mostRecent = data.waveData['21-22'] || []
|
||||
|
||||
// Chart 1: Arrests over time by wave (total + rate per 1k labels)
|
||||
const timeSeriesData = ['15-16', '17-18', '21-22'].map(year => {
|
||||
const yearRows = data.waveData[year] || []
|
||||
// Sum observed_arrests and stu_enroll across all groups for this wave
|
||||
const totalArrests = (yearRows || []).reduce((sum, r) => sum + (r.observed_arrests || 0), 0)
|
||||
const totalEnroll = (yearRows || []).reduce((sum, r) => sum + (r.stu_enroll || 0), 0)
|
||||
return { year, label: `20${year.replace('-', '-')}`, arrests: totalArrests, enroll: totalEnroll }
|
||||
})
|
||||
|
||||
// Chart 2: Rate by student group (most recent wave, per 1k)
|
||||
const rateByGroup = mostRecent.map(r => ({
|
||||
race: r.race, sex: r.sex, label: getStudentLabel(r.race, r.sex),
|
||||
observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000),
|
||||
modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000),
|
||||
observedArrests: r.observed_arrests || 0,
|
||||
enrollment: r.stu_enroll || 0
|
||||
})).sort((a, b) => b.observedRate - a.observedRate)
|
||||
|
||||
// Chart 3: District vs national — top student group by rate
|
||||
const topGroup = rateByGroup[0] || { race: 'BL', sex: 'M' }
|
||||
const natKey = `${topGroup.race}_${topGroup.sex}`
|
||||
const nationalTopRate = nationalRates?.rates_per_1000?.[natKey] || 0
|
||||
|
||||
// Chart 4-6 use quadData (4 models × 8 groups)
|
||||
const chart4Data = { timeSeries: mostRecent, waves: data.waveData }
|
||||
const chart5Data = data.quadData // all 4 quadrant model results
|
||||
const chart6Data = { rates: rateByGroup, nationalRates: nationalRates?.rates_per_1000 || {} }
|
||||
|
||||
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>
|
||||
|
||||
{/* Responsive grid: 3 columns on desktop, 1 on mobile */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 'var(--space-4)'
|
||||
}}>
|
||||
{/* Row 1 — Observed & descriptive */}
|
||||
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
|
||||
|
||||
<RateByGroupBar data={rateByGroup} />
|
||||
|
||||
<DistrictVsNational topGroup={topGroup} nationalRate={nationalTopRate} rateByGroup={rateByGroup} />
|
||||
|
||||
{/* Row 2 — Bayesian model distributions */}
|
||||
<ModelDrawsComparison data={chart4Data} waves={['15-16', '17-18', '21-22']} districtName={district.lea_name} />
|
||||
|
||||
<ObservedRateDensity quadData={chart5Data} />
|
||||
|
||||
<ExceedanceProbability rateByGroup={rateByGroup} nationalRates={nationalRates?.rates_per_1000 || {}} />
|
||||
</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 compares 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 getStudentLabel(race, sex) {
|
||||
const labels = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'American Indian/Alaskan Native' }
|
||||
return `${labels[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`.replace('American Indian/Alaskan Native', 'AI/AN')
|
||||
}
|
||||
|
||||
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-2)' }}>Organizing data across all model specifications…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import ChartPanel from './ChartPanel.jsx'
|
||||
|
||||
// The 8 student groups we fetch for (race×sex combinations)
|
||||
const STUDENT_GROUPS = [
|
||||
{ race: 'WH', sex: 'F', label: 'White F' },
|
||||
{ race: 'WH', sex: 'M', label: 'White M' },
|
||||
{ race: 'BL', sex: 'F', label: 'Black F' },
|
||||
{ race: 'BL', sex: 'M', label: 'Black M' },
|
||||
{ race: 'HI', sex: 'F', label: 'Hispanic F' },
|
||||
{ race: 'HI', sex: 'M', label: 'Hispanic M' },
|
||||
{ race: 'AM', sex: 'F', label: 'AI/AN F' },
|
||||
{ race: 'AM', sex: 'M', label: 'AI/AN M' },
|
||||
]
|
||||
|
||||
// The "four quadrants" of models for distribution charts (Charts 4–6)
|
||||
const MODEL_QUADRANTS = [
|
||||
{ model: 'unified_m1_mod', label: 'One-year, baseline', col: 0, row: 0 },
|
||||
{ model: 'unified_m2_mod', label: 'One-year + covariate', col: 0, row: 1 },
|
||||
{ model: 'unified_m3_mod', label: 'Three-year, baseline', col: 1, row: 0 },
|
||||
{ model: 'unified_m4_mod', label: 'Three-year + covariate', col: 1, row: 1 },
|
||||
]
|
||||
|
||||
// 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 6 charts
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let count = 0
|
||||
let total = 0
|
||||
|
||||
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 */ })
|
||||
)
|
||||
|
||||
// ——— Charts 2 & 3: 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(() => {})
|
||||
)
|
||||
|
||||
// ——— Charts 4–6: Model comparison (8 groups × 4 quadrant models = 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(() => {})
|
||||
)
|
||||
)
|
||||
|
||||
// ——— National rates (from static fixture, instant) ———
|
||||
const nationalPromise = fetch('/data/national_rates.json')
|
||||
.then(r => r.json())
|
||||
.then(data => { /* cache for ChartPanel */ window.__NATIONAL_RATES__ = data })
|
||||
.catch(() => {})
|
||||
|
||||
// Set total before starting (for progress bar)
|
||||
const allPromises = [...wavePromises, ...groupPromises, ...modelPromises]
|
||||
if (!cancelled) setTotalCalls(allPromises.length + 1 /* national */)
|
||||
|
||||
await Promise.all([...allPromises, nationalPromise])
|
||||
|
||||
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 + 1)
|
||||
}
|
||||
} 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
|
||||
const allBars = [
|
||||
...WAVES.map((y, i) => ({ label: `Wave ${y}`, subLabel: 'Time series', col: 0, row: i })),
|
||||
...STUDENT_GROUPS.map((sg, i) => ({ label: sg.label, subLabel: 'Rate by group', col: 1, row: i })),
|
||||
...MODEL_QUADRANTS.flatMap((q, qi) =>
|
||||
STUDENT_GROUPS.map((sg, si) => ({
|
||||
label: `${q.model.split('_m')[0].substring(0,3).toUpperCase()} M${qi+1}`,
|
||||
subLabel: sg.label,
|
||||
col: 2 + qi % 2, row: si, quadrant: q
|
||||
}))
|
||||
),
|
||||
]
|
||||
|
||||
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)', 'Observed rates (Charts 2–3)', 'Model comparisons (Charts 4–6)'].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.slice(0, totalCalls || allBars.length).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>
|
||||
)
|
||||
}
|
||||
|
||||
// ——— Inline fetch helper (avoids circular import with useApi.js) ———
|
||||
async function fetchDistrictEstimatesBatch(leaid, year, race = null, sex = null, model = 'unified_m2_mod') {
|
||||
const params = new URLSearchParams({ leaid, year, model })
|
||||
if (race) params.set('race', race)
|
||||
if (sex) params.set('sex', sex)
|
||||
const res = await fetch(`https://crdc-api.civilytics.org/api/v1/estimates/${leaid}?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// All 50 states + DC, matching the API's ALLOWED_STATES
|
||||
const STATES = [
|
||||
'AL','AK','AZ','AR','CA','CO','CT','DE','DC','FL','GA','HI',
|
||||
'ID','IL','IN','IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT',
|
||||
'NE','NV','NH','NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD',
|
||||
'TN','TX','UT','VT','VA','WA','WV','WI','WY'
|
||||
]
|
||||
|
||||
const STATE_NAMES = {
|
||||
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
||||
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', DC: 'District of Columbia', FL: 'Florida',
|
||||
GA: 'Georgia', HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
||||
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland', MA: 'Massachusetts',
|
||||
MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri', MT: 'Montana',
|
||||
NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey', NM: 'New Mexico',
|
||||
NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma',
|
||||
OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota',
|
||||
TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia',
|
||||
WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming'
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing screen — prompts user to select a state.
|
||||
* Matches the Civilytics visual style with eyebrow label, stat callout, and clean card layout.
|
||||
*/
|
||||
export default function StateSelector({ onNext }) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// Filter states by code or name as user types
|
||||
const filtered = STATES.filter(
|
||||
(code) =>
|
||||
code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
STATE_NAMES[code].toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-5) 0 var(--space-7)' }}>
|
||||
{/* Eyebrow */}
|
||||
<span className="eyebrow">US Department of Education Civil Rights Data Collection</span>
|
||||
|
||||
{/* Hero */}
|
||||
<h1 style={{ marginTop: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
|
||||
Explore school-based arrest rates by district
|
||||
</h1>
|
||||
|
||||
<p style={{ fontSize: '1.1rem', lineHeight: 1.7, maxWidth: '48rem' }}>
|
||||
The Civil Rights Data Collection (CRDC) is the only source of data on school-related
|
||||
arrests for all U.S. public schools and districts. Enter a state to see estimates for
|
||||
any district — with Bayesian model comparisons that reveal how confident we can be in
|
||||
each rate, even when arrests are rare events.
|
||||
</p>
|
||||
|
||||
{/* Stat callout — key context */}
|
||||
<div className="stat-callout" style={{ marginTop: 'var(--space-4)' }}>
|
||||
<div>
|
||||
<span className="stat-num">34,846</span>
|
||||
<span className="stat-label">students arrested in 2021–22</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="stat-num">0.72</span>
|
||||
<span className="stat-label accent">arrests per 1,000 students (national rate)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="stat-num">11.6%</span>
|
||||
<span className="stat-label">of districts reported >0 arrests</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div style={{ marginTop: 'var(--space-5)' }}>
|
||||
<label htmlFor="state-search" style={{ display: 'block', marginBottom: 'var(--space-2)', fontWeight: 600 }}>
|
||||
Select a state to begin:
|
||||
</label>
|
||||
<input
|
||||
id="state-search"
|
||||
type="text"
|
||||
placeholder="Type a state name or code (e.g., Texas, TX)…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: 'var(--space-2)', fontSize: '1rem',
|
||||
border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)',
|
||||
fontFamily: 'var(--font-sans)'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Filtered grid of states */}
|
||||
{filtered.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 'var(--space-1)',
|
||||
marginTop: 'var(--space-3)'
|
||||
}}>
|
||||
{filtered.map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
className="cv-card"
|
||||
style={{ textAlign: 'center', padding: 'var(--space-2)' }}
|
||||
onClick={() => onNext(code)}
|
||||
>
|
||||
<span style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--cv-navy-600)' }}>
|
||||
{code}
|
||||
</span>
|
||||
<span className="stat-label" style={{ marginTop: '0.25rem' }}>
|
||||
{STATE_NAMES[code]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p style={{ color: 'var(--cv-ink-3)', marginTop: 'var(--space-2)' }}>
|
||||
No states match "{query}". Try a full state name or two-letter code.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context footer */}
|
||||
<div style={{ marginTop: 'var(--space-5)', paddingBottom: 'var(--space-3)', borderBottom: '1px solid var(--cv-rule)' }}>
|
||||
<span className="eyebrow">How it works</span>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-1)' }}>
|
||||
After selecting a state, you'll search for a school district and see arrest estimates from 10 Bayesian models —
|
||||
including one-year vs. three-year specifications and baseline vs. referral-rate covariate models.
|
||||
The app fetches data directly from the{' '}
|
||||
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">
|
||||
CRDC Arrest Rate API
|
||||
</a>. No account or key required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"metadata": {
|
||||
"source": "CRDC School Arrest Rate API / Knowles & Miller 2025, White Paper §3 Data",
|
||||
"year": "21-22",
|
||||
"description": "National arrest rates per 1,000 students for each race×sex group in the 2021-22 CRDC. Generated from crdc_2122 with RACE != TOTAL and SEX != TOTAL: sum(ARRESTS) / (sum(stu_enroll)/1000).",
|
||||
"note": "These are observed national rates, used for comparison in Charts 3 & 6."
|
||||
},
|
||||
"rates_per_1000": {
|
||||
"WH_F": 0.34,
|
||||
"WH_M": 0.67,
|
||||
"BL_F": 1.25,
|
||||
"BL_M": 1.98,
|
||||
"HI_F": 0.42,
|
||||
"HI_M": 0.88,
|
||||
"AM_F": 0.91,
|
||||
"AM_M": 1.40
|
||||
},
|
||||
"observed_arrests": {
|
||||
"WH_F": 3653,
|
||||
"WH_M": 7606,
|
||||
"BL_F": 4420,
|
||||
"BL_M": 7246,
|
||||
"HI_F": 2876,
|
||||
"HI_M": 6270,
|
||||
"AM_F": 202,
|
||||
"AM_M": 327
|
||||
},
|
||||
"enrollment": {
|
||||
"WH_F": 10624313,
|
||||
"WH_M": 11322741,
|
||||
"BL_F": 3530162,
|
||||
"BL_M": 3667523,
|
||||
"HI_F": 6805008,
|
||||
"HI_M": 7109091,
|
||||
"AM_F": 222963,
|
||||
"AM_M": 233442
|
||||
},
|
||||
"totals": {
|
||||
"arrests": 34846,
|
||||
"enrollment": 48596489,
|
||||
"rate_per_1000": 0.72,
|
||||
"districts_reporting_arrests_pct": 11.6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Civilytics CRDC Arrest Rate API client.
|
||||
* Wraps fetch() with retry, backoff, and typed error handling.
|
||||
* Base URL: https://crdc-api.civilytics.org/api/v1
|
||||
*/
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'https://crdc-api.civilytics.org/api/v1'
|
||||
|
||||
// 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 (Chart 4–6)
|
||||
export const MODEL_QUADRANTS = [
|
||||
{ model: 'unified_m1_mod', label: 'One-year, no covariate', row: 0, col: 0 },
|
||||
{ model: 'unified_m2_mod', label: 'One-year + referral rate', row: 1, col: 0 },
|
||||
{ model: 'unified_m3_mod', label: 'Three-year, no covariate', row: 0, col: 1 },
|
||||
{ model: 'unified_m4_mod', label: 'Three-year + referral rate', row: 1, col: 1 },
|
||||
]
|
||||
|
||||
// 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 } = {}) {
|
||||
const url = `${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 = await res.json()
|
||||
if (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}`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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>,
|
||||
)
|
||||
@@ -0,0 +1,266 @@
|
||||
/* =============================================================
|
||||
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;
|
||||
|
||||
/* — Chart-specific colors — */
|
||||
--chart-modeled: #000a9bff; /* dark indigo-blue (from wp_model_palette) */
|
||||
--chart-frequentist: #858585bb; /* gray (from wp_model_palette) */
|
||||
--chart-observed: #A6271D; /* red for observed data points */
|
||||
|
||||
/* — Model quadrant colors — */
|
||||
--model-one-year-baseline: var(--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; } }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
build: { outDir: 'dist', sourcemap: true },
|
||||
})
|
||||
Reference in New Issue
Block a user