From a1ffa600b384460d4dff8ba9466ae1eb6c60fd1e Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 10 Aug 2026 19:27:24 -0400 Subject: [PATCH] Reduce demo to 3 charts, fix back-button district name bug Fixes the deep-link/back-button bug where the district name showed "Unknown District" on return: App.jsx was passing the LEAID as a text search query to searchDistricts() (a name search), which never matches a numeric ID. Resolves it instead via fetchDistrictEstimates(leaid, ...) and reads lea_name/state directly from the returned row - correct regardless of how the page was reached (fresh load, refresh, or browser back/forward). Cuts the demo from 6 charts to 3, per request: arrests over time (kept), arrest rate by student group restructured into Female/Male box-and-whisker panels (kept), and the posterior density ridge chart restructured from a 2x2 model-quadrant grid into a single selected model (dropdown, default three-year + referral rate) with Female/Male ridge columns. Removes DistrictVsNational, ModelDrawsComparison, and ExceedanceProbability entirely, along with the student-group filter (no longer needed - the remaining charts always show the full breakdown) and the national-rates fetch/plumbing that only those removed charts used. Also updates LoadingAnimation's copy and dedupes its STUDENT_GROUPS/ MODEL_QUADRANTS constants against the shared ones in useApi.js. Co-Authored-By: Claude Sonnet 5 --- src/App.jsx | 24 +-- src/charts/DistrictVsNational.jsx | 98 ------------ src/charts/ExceedanceProbability.jsx | 127 ---------------- src/charts/ModelDrawsComparison.jsx | 143 ------------------ src/charts/RateByGroupBar.jsx | 183 +++++++++++------------ src/charts/RateDensityRidgeline.jsx | 215 ++++++++++++++------------- src/components/ChartPanel.jsx | 125 +++++----------- src/components/GroupFilterBar.jsx | 66 -------- src/components/LoadingAnimation.jsx | 52 ++----- src/data/national_rates.json | 44 ------ src/hooks/useGroupFilter.js | 36 ----- src/utils/colors.js | 6 +- src/utils/distributionApprox.js | 5 - src/utils/groupKey.js | 13 -- 14 files changed, 263 insertions(+), 874 deletions(-) delete mode 100644 src/charts/DistrictVsNational.jsx delete mode 100644 src/charts/ExceedanceProbability.jsx delete mode 100644 src/charts/ModelDrawsComparison.jsx delete mode 100644 src/components/GroupFilterBar.jsx delete mode 100644 src/data/national_rates.json delete mode 100644 src/hooks/useGroupFilter.js delete mode 100644 src/utils/groupKey.js diff --git a/src/App.jsx b/src/App.jsx index d4d1869..cd27b8b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,7 +4,7 @@ import DistrictSearch from './components/DistrictSearch.jsx' import LoadingAnimation from './components/LoadingAnimation.jsx' import ChartPanel from './components/ChartPanel.jsx' import Footer from './components/Footer.jsx' -import { searchDistricts } from './hooks/useApi.js' +import { fetchDistrictEstimates } from './hooks/useApi.js' /** * CRDC Arrests API Demo App — main router. @@ -21,18 +21,18 @@ export default function App() { const leaid = params.get('leaid') const stateParam = params.get('state') if (leaid && stateParam) { - // Deep link: fetch district name first, then go to loading. + // Deep link (including browser back/forward landing on this URL): resolve + // the district name via a real estimates row, keyed by LEAID. The previous + // version called searchDistricts(leaid, ...), a name/text search — passing + // an LEAID as search text never matches, so it always fell back to "Unknown + // District". setSelectedState(stateParam) - - // Fetch district name from API - searchDistricts(leaid, stateParam).then(results => { - const district = results?.find(d => d.leaid === leaid) - if (district) { - setDistrict(district) - } else { - // Fallback if district not found - setDistrict({ leaid, lea_name: 'Unknown District', state: stateParam }) - } + + fetchDistrictEstimates(leaid, { model: 'unified_m2_mod', year: '21-22' }).then(rows => { + const row = rows?.[0] + setDistrict(row + ? { leaid: row.leaid, lea_name: row.lea_name, state: row.state } + : { leaid, lea_name: 'Unknown District', state: stateParam }) setStep('loading') }).catch(() => { // Fallback on error diff --git a/src/charts/DistrictVsNational.jsx b/src/charts/DistrictVsNational.jsx deleted file mode 100644 index 54f078d..0000000 --- a/src/charts/DistrictVsNational.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import ChartLegend from '../components/ChartLegend.jsx' -import { raceColor, OBSERVED_MARK_COLOR, REFERENCE_GRAY } from '../utils/colors.js' - -/** - * Chart 3: The district's highest-rate student group vs. the national rate - * for that same group. District bar = modeled median (race-colored) with a - * 90% interval whisker; dark diamond = observed rate on the same bar. - * National bar is a neutral gray reference, not a race/model color. - */ - -export default function DistrictVsNational({ topGroup, nationalRate, rateByGroup }) { - const width = 360 - const height = 300 - const margin = { top: 45, right: 30, bottom: 80, left: 70 } - const innerWidth = width - margin.left - margin.right - const plotHeight = height - margin.top - margin.bottom - - const districtObserved = topGroup.observedRate || 0 - const districtModeled = topGroup.modeledMedian ?? districtObserved - const districtLower = topGroup.rateLower ?? districtModeled - const districtUpper = topGroup.rateUpper ?? districtModeled - const maxVal = Math.max(districtUpper, districtObserved, nationalRate, 1) * 1.3 - - const yFor = (val) => margin.top + plotHeight * (1 - val / maxVal) - const color = raceColor(topGroup.race) - - const districtBarX = margin.left + innerWidth * 0.1 - const districtBarW = innerWidth * 0.35 - const nationalBarX = margin.left + innerWidth * 0.55 - const nationalBarW = innerWidth * 0.35 - - return ( -
-

- District vs. national rate — highest group -

- - - - - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const val = frac * maxVal - const y = yFor(val) - return ( - - - {val.toFixed(1)} - - ) - })} - - - Rate per 1,000 students - - - {/* District bar — modeled median, race-colored, with 90% interval whisker */} - - - {/* Observed diamond, overlaid on the district bar */} - - - {/* National bar — neutral reference */} - - {nationalRate.toFixed(2)} - - District - National - - - {topGroup.label || 'Top group'} - - - - - -

- {topGroup.label || 'Highest-rate group'} in this district has an observed rate of{' '} - {districtObserved.toFixed(2)} per 1,000 vs. the national average of{' '} - {nationalRate.toFixed(2)}. This is{' '} - {districtObserved > nationalRate * 1.5 ? 'substantially higher than' : districtObserved > nationalRate - ? 'slightly higher than' : districtObserved < nationalRate * 0.75 - ? 'much lower than' - : 'comparable to'} the national rate. -

-
- ) -} diff --git a/src/charts/ExceedanceProbability.jsx b/src/charts/ExceedanceProbability.jsx deleted file mode 100644 index e980189..0000000 --- a/src/charts/ExceedanceProbability.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState } from 'react' -import { groupKey } from '../utils/groupKey.js' -import { shortGroupLabel } from '../utils/colors.js' -import { fitSkewedInterval, probabilityExceeds } from '../utils/distributionApprox.js' -import ApproxNote from '../components/ApproxNote.jsx' - -/** - * Chart 6: Probability the district exceeds the national rate for each - * student group, under a chosen model. The model dropdown now actually - * drives the computation (it was previously decorative — computed always - * read from a single ChartPanel-precomputed default model). - */ - -const MODEL_OPTIONS = [ - { val: 'unified_m2_mod', label: 'Recommended (one-year + covariate)' }, - { val: 'unified_m4_mod', label: 'Three-year + covariate' }, -] - -export default function ExceedanceProbability({ quadData, nationalRates }) { - const [selectedModel, setSelectedModel] = useState('unified_m2_mod') - - const rows = (quadData && quadData[selectedModel]) || [] - const computed = rows - .map((row) => { - const natKey = groupKey(row.race, row.sex) - const nationalRate = nationalRates[natKey] || 0 - const rateMedian = (row.rate_median || 0) * 1000 - const rateLower = (row.rate_lower || 0) * 1000 - const rateUpper = (row.rate_upper || 0) * 1000 - const fit = fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }) - const probExceeds = Math.max(0.01, Math.min(0.99, probabilityExceeds(fit, nationalRate))) - return { race: row.race, sex: row.sex, label: shortGroupLabel(row.race, row.sex), nationalRate, modeledRate: rateMedian, probExceeds } - }) - .sort((a, b) => b.probExceeds - a.probExceeds) - - const width = 400 - const height = 320 - const margin = { top: 50, right: 100, bottom: 60, left: 90 } - const innerWidth = width - margin.left - margin.right - const plotHeight = height - margin.top - margin.bottom - - return ( -
-

- Probability district exceeds national rate — by student group -

- - - - - {computed.length === 0 ? ( -

No model data available for the selected groups.

- ) : ( - - - - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const y = margin.top + plotHeight - frac * plotHeight - return ( - - - {frac.toFixed(2)} - - ) - })} - - - P(district rate > national) - - - {/* 50% reference line — coin-flip threshold, not tied to any one group's national rate */} - - - {computed.map((d, i) => { - const barHeight = Math.min(22, (plotHeight * 0.9) / computed.length) - const y = margin.top + i * (barHeight + 4) - const probWidth = d.probExceeds * innerWidth - - let barColor - if (d.probExceeds < 0.5) barColor = 'var(--moss-600)' - else if (d.probExceeds < 0.8) barColor = 'var(--brass-600)' - else barColor = 'var(--cv-danger)' - - return ( - - - {d.probExceeds > 0.05 && ( - - {Math.round(d.probExceeds * 100)}% - - )} - - {d.label} - - - ) - })} - - - Probability (higher = district more likely to exceed national rate) - - - - - < 50% - - 50–80% - - > 80% - - - )} - -

- Based on the {selectedModel === 'unified_m2_mod' ? 'one-year + covariate model (recommended)' : 'three-year + covariate model'}. - Green bars indicate groups where this district's rate is unlikely to exceed the national average; red indicates likely exceedance. -

-
- ) -} diff --git a/src/charts/ModelDrawsComparison.jsx b/src/charts/ModelDrawsComparison.jsx deleted file mode 100644 index e867c15..0000000 --- a/src/charts/ModelDrawsComparison.jsx +++ /dev/null @@ -1,143 +0,0 @@ -import { MODEL_QUADRANTS } from '../hooks/useApi.js' -import ChartLegend from '../components/ChartLegend.jsx' -import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js' - -/** - * Chart 4: Modeled vs. observed arrests, faceted by the four model-quadrant - * specifications (one-year/three-year × baseline/+referral-rate covariate). - * Plain SVG (no D3) — the quadrant label already conveys 1yr/3yr, so all - * four panels share one ink-diamond/navy-point-range convention rather than - * doubling up with a redundant per-quadrant hue. - */ - -const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' } -const ALL_WAVES = ['15-16', '17-18', '21-22'] - -/** Sums count_median/lower/upper across every group row for one wave — the - * modeled counterpart to waveData's observed-arrests sum. Each row is a - * single race×sex group, so picking just one row (the original bug here) - * silently reports whichever group happens to come first from the API. */ -function aggregateModeledForYear(rows, year) { - const yearRows = (rows || []).filter((r) => r.year === year) - if (yearRows.length === 0) return null - return { - median: yearRows.reduce((sum, r) => sum + (r.count_median || 0), 0), - lower: yearRows.reduce((sum, r) => sum + (r.count_lower || 0), 0), - upper: yearRows.reduce((sum, r) => sum + (r.count_upper || 0), 0), - } -} - -export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) { - const waveData = waves.map((year) => { - const rows = (data.waves && data.waves[year]) || [] - return { year, arrests: rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0) } - }) - - const quadModels = (data && data.quadData) || {} - - const allValues = waveData.map((d) => d.arrests) - Object.entries(quadModels).forEach(([model, rows]) => { - const isThreeYear = model.includes('m3') || model.includes('m4') - const years = isThreeYear ? ALL_WAVES : ['21-22'] - years.forEach((year) => { - const agg = aggregateModeledForYear(rows, year) - if (agg) allValues.push(agg.median, agg.upper) - }) - }) - const maxArrests = Math.min(Math.max(...allValues, 1), 50) // cap for readability - - return ( -
-

- Model predictions vs. observed — by wave & model specification -

- -
- {MODEL_QUADRANTS.map((q) => ( - - ))} -
- - - -

- Three-year models span all three CRDC waves; one-year models show 2021–22 only.{' '} - {districtName ? `District: ${districtName}.` : ''} -

-
- ) -} - -function QuadrantPanel({ q, waveData, quadModels, maxArrests }) { - const isThreeYear = q.model.includes('m3') || q.model.includes('m4') - const showYears = isThreeYear ? ALL_WAVES : ['21-22'] - - const width = 260 - const height = 190 - const margin = { top: 26, right: 12, bottom: 26, left: 34 } - const innerWidth = width - margin.left - margin.right - const innerHeight = height - margin.top - margin.bottom - - const xScale = (i) => margin.left + (showYears.length > 1 ? (i / (showYears.length - 1)) * innerWidth : innerWidth / 2) - const yScale = (val) => margin.top + innerHeight - (Math.min(val, maxArrests) / maxArrests) * innerHeight - - return ( -
- - - {q.label} - - - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const val = frac * maxArrests - const y = yScale(val) - return ( - - - {Math.round(val)} - - ) - })} - - {showYears.map((year, i) => { - const x = xScale(i) - const obs = waveData.find((d) => d.year === year)?.arrests ?? 0 - const agg = aggregateModeledForYear(quadModels[q.model], year) - const obsY = yScale(obs) - // Edge ticks anchor away from the viewBox boundary instead of centering - // on it, so the label can't be clipped by the SVG edge. - const tickAnchor = i === 0 ? 'start' : i === showYears.length - 1 ? 'end' : 'middle' - const tickX = i === 0 ? x - 4 : i === showYears.length - 1 ? x + 4 : x - - return ( - - - {WAVE_LABELS[year]} - - - {agg && ( - <> - - - - )} - - - - ) - })} - -
- ) -} diff --git a/src/charts/RateByGroupBar.jsx b/src/charts/RateByGroupBar.jsx index 60456e6..6077782 100644 --- a/src/charts/RateByGroupBar.jsx +++ b/src/charts/RateByGroupBar.jsx @@ -4,20 +4,16 @@ import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/color import { fitSkewedInterval } from '../utils/distributionApprox.js' /** - * Chart 2: Arrest rate per 1,000 students by student group (most recent - * year). A plain bar to the modeled median (next to an observed diamond - * that often sits well past the bar's end) reads as if the bar itself were - * the uncertainty range, which it wasn't. This draws an actual horizontal - * box-and-whisker instead: whisker = the API's reported 90% interval - * (rateLower/rateUpper), box = the fitted approximation's 25th/75th - * percentiles, median tick = the modeled median. Dark diamond = observed, - * overlaid at its own position. Sex is row grouping, not color. + * Arrest rate by student group, most recent year, disaggregated into two + * panels (Female / Male), each a horizontal box-and-whisker across the 4 + * race categories. Whisker = the model's reported 90% interval, box = the + * fitted approximation's 25th-75th percentile, white tick = median, dark + * diamond = observed rate. */ const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] -const SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] -const ROW_HEIGHT = 26 -const SUBHEADER_HEIGHT = 20 +const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] +const ROW_HEIGHT = 34 function buildBox(d) { const median = Math.max(d.modeledMedian || 0, 0) @@ -25,37 +21,17 @@ function buildBox(d) { const upper = Math.max(d.rateUpper ?? median, median) const fit = fitSkewedInterval({ median, lower, upper }) return { - lower, - upper, - median, + lower, upper, median, q1: Math.max(fit.quantile(0.25), 0), q3: Math.max(fit.quantile(0.75), median), } } export default function RateByGroupBar({ data }) { - const width = 360 - const margin = { top: 40, right: 30, bottom: 45, left: 90 } - const innerWidth = width - margin.left - margin.right - - const bySex = SEX_ROWS - .map(({ sex, label }) => ({ - sex, - label, - rows: RACE_ORDER.map((race) => data.find((d) => d.race === race && d.sex === sex)).filter(Boolean), - })) - .filter((g) => g.rows.length > 0) - - const bodyHeight = bySex.reduce((sum, g) => sum + SUBHEADER_HEIGHT + g.rows.length * ROW_HEIGHT, 0) - const height = margin.top + bodyHeight + margin.bottom - const maxRate = Math.max( ...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)), 0.5 ) - const xScale = (val) => margin.left + (val / maxRate) * innerWidth - - let cursorY = margin.top return (
@@ -64,74 +40,11 @@ export default function RateByGroupBar({ data }) { - - - - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const val = frac * maxRate - const x = xScale(val) - return ( - - - - {val.toFixed(1)} - - - ) - })} - - {bySex.map((group) => { - const subheaderY = cursorY - cursorY += SUBHEADER_HEIGHT - const rowEls = group.rows.map((d) => { - const rowY = cursorY - cursorY += ROW_HEIGHT - const color = raceColor(d.race) - const box = buildBox(d) - const midY = rowY + ROW_HEIGHT / 2 - const boxTop = midY - ROW_HEIGHT * 0.28 - const boxBottom = midY + ROW_HEIGHT * 0.28 - const observedX = xScale(d.observedRate) - - return ( - - {/* Whisker: the API's reported 90% interval */} - - - - {/* Box: fitted 25th-75th percentile */} - - {/* Median tick */} - - {/* Observed diamond */} - - - {SHORT_RACE_LABEL[d.race] || d.race} - - - ) - }) - return ( - - - {group.label} - - {rowEls} - - ) - })} - - - Rate per 1,000 students - - +
+ {SEX_PANELS.map(({ sex, label }) => ( + d.sex === sex)} maxRate={maxRate} /> + ))} +
data.some((d) => d.race === race)).map((race) => ({ @@ -147,3 +60,73 @@ export default function RateByGroupBar({ data }) {
) } + +function SexPanel({ label, rows, maxRate }) { + const width = 300 + const margin = { top: 30, right: 16, bottom: 34, left: 66 } + const innerWidth = width - margin.left - margin.right + const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean) + const bodyHeight = byRace.length * ROW_HEIGHT + const height = margin.top + bodyHeight + margin.bottom + const xScale = (val) => margin.left + (val / maxRate) * innerWidth + + return ( +
+ + + {label} + + + + + {[0, 0.25, 0.5, 0.75, 1].map((frac) => { + const val = frac * maxRate + const x = xScale(val) + return ( + + + + {val.toFixed(1)} + + + ) + })} + + {byRace.map((d, i) => { + const rowY = margin.top + i * ROW_HEIGHT + const midY = rowY + ROW_HEIGHT / 2 + const boxTop = midY - ROW_HEIGHT * 0.26 + const boxBottom = midY + ROW_HEIGHT * 0.26 + const color = raceColor(d.race) + const box = buildBox(d) + const observedX = xScale(d.observedRate) + + return ( + + + + + + + + + {SHORT_RACE_LABEL[d.race] || d.race} + + + ) + })} + + + Rate per 1,000 students + + +
+ ) +} diff --git a/src/charts/RateDensityRidgeline.jsx b/src/charts/RateDensityRidgeline.jsx index 3978f08..ab5778f 100644 --- a/src/charts/RateDensityRidgeline.jsx +++ b/src/charts/RateDensityRidgeline.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { MODEL_QUADRANTS } from '../hooks/useApi.js' import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js' import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js' @@ -5,15 +6,16 @@ import ChartLegend from '../components/ChartLegend.jsx' import ApproxNote from '../components/ApproxNote.jsx' /** - * Chart 5: Modeled posterior density per race×sex group, faceted 2×2 by - * model quadrant (matches whitepaper-fig-clark-density-1.png). Plain SVG — - * no D3, no fake normal draws: each ridge is drawn as 60 analytic points - * from fitSkewedInterval/densityCurve, which is already smooth without a - * binning/curveBasis smoothing pass. + * Modeled posterior density per race×sex group, for one selected model + * (dropdown, default three-year + referral rate), split into Female/Male + * columns — matches whitepaper-fig-clark-density-1.png's ridge style. Plain + * SVG — each ridge is 60 analytic points from fitSkewedInterval/densityCurve, + * already smooth without a binning/curveBasis smoothing pass. */ const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] -const SEX_ORDER = ['F', 'M'] +const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] +const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate function buildGroupRow(row) { const enroll = row.stu_enroll || 0 @@ -30,133 +32,140 @@ function buildGroupRow(row) { } export default function RateDensityRidgeline({ quadData }) { - const hasData = quadData && Object.values(quadData).some((rows) => (rows || []).length > 0) + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL) + const rows = (quadData && quadData[selectedModel]) || [] - if (!hasData) { - return ( -
-

- Predicted arrest rates by student group -

-

No model data available.

-
- ) - } - - // Shared x-domain across all four quadrant panels, so they're directly comparable. - const allUpper = [] - const allObserved = [] - MODEL_QUADRANTS.forEach((q) => { - (quadData[q.model] || []).forEach((row) => { - allUpper.push((row.rate_upper || 0) * 1000) - const enroll = row.stu_enroll || 0 - if (enroll > 0) allObserved.push(((row.observed_arrests || 0) / enroll) * 1000) - }) - }) - const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30) + const modelSelect = ( + + ) return (
-

- Predicted arrest rates by student group -

- - -
- {MODEL_QUADRANTS.map((q) => ( - - ))} +
+
+

+ Predicted arrest rates by student group +

+ +
+ {modelSelect}
- ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })), - { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, - ]} /> + {rows.length === 0 ? ( +

No model data available.

+ ) : ( + <> + + ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })), + { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, + ]} /> + + )}
) } -function RidgePanel({ q, rows, maxRate }) { - const groups = [] - SEX_ORDER.forEach((sex) => { - RACE_ORDER.forEach((race) => { - const row = rows.find((r) => r.race === race && r.sex === sex) - if (row) groups.push(buildGroupRow(row)) - }) - }) +function RidgeColumns({ rows }) { + // Shared x-domain across both columns, so Female/Male are directly comparable. + const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000) + const allObserved = rows + .filter((r) => (r.stu_enroll || 0) > 0) + .map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000) + const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30) - if (groups.length === 0) { - return ( -
-

{q.label} — no data for the selected groups

-
- ) - } + return ( +
+ {SEX_COLUMNS.map(({ sex, label }) => ( + r.sex === sex)} maxRate={maxRate} /> + ))} +
+ ) +} + +function SexRidgeColumn({ label, rows, maxRate }) { + const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow) const width = 300 - const rowHeight = 46 - const margin = { top: 24, right: 16, bottom: 26, left: 56 } + const rowHeight = 58 + const margin = { top: 26, right: 16, bottom: 26, left: 56 } const innerWidth = width - margin.left - margin.right - const height = margin.top + groups.length * rowHeight + margin.bottom + const height = margin.top + Math.max(groups.length, 1) * rowHeight + margin.bottom const xScale = (val) => margin.left + (val / maxRate) * innerWidth const curves = groups.map((g) => densityCurve(g.fit, { min: 0, max: maxRate, n: 60 })) const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9) - const peakHeight = rowHeight * 0.85 + const peakHeight = rowHeight * 0.82 return (
- - {q.label} + + {label} - {[0, 0.25, 0.5, 0.75, 1].map((frac) => { - const val = frac * maxRate - const x = xScale(val) - return ( - - - - {val.toFixed(0)} - - - ) - })} + {groups.length === 0 ? ( + + No data for this group + + ) : ( + <> + {[0, 0.25, 0.5, 0.75, 1].map((frac) => { + const val = frac * maxRate + const x = xScale(val) + return ( + + + + {val.toFixed(0)} + + + ) + })} - {groups.map((g, i) => { - const rowTop = margin.top + i * rowHeight - const baselineY = rowTop + rowHeight * 0.9 - const curve = curves[i] - const color = raceColor(g.race) + {groups.map((g, i) => { + const rowTop = margin.top + i * rowHeight + const baselineY = rowTop + rowHeight * 0.9 + const curve = curves[i] + const color = raceColor(g.race) - const topPath = curve - .map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`) - .join(' ') - const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z` + const topPath = curve + .map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`) + .join(' ') + const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z` - const obsX = xScale(Math.min(g.observedRate, maxRate)) - const obsY = baselineY - peakHeight * 0.15 + const obsX = xScale(Math.min(g.observedRate, maxRate)) + const obsY = baselineY - peakHeight * 0.15 - return ( - - - {SHORT_RACE_LABEL[g.race] || g.race} {g.sex} - - - - - ) - })} + return ( + + + {SHORT_RACE_LABEL[g.race] || g.race} + + + + + ) + })} - - Arrests per 1,000 students - + + Arrests per 1,000 students + + + )}
) diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx index 59cb87d..51429d9 100644 --- a/src/components/ChartPanel.jsx +++ b/src/components/ChartPanel.jsx @@ -1,49 +1,34 @@ import { useState, useEffect } from 'react' import * as api from '../hooks/useApi.js' -import useGroupFilter, { filterRowsByGroup } from '../hooks/useGroupFilter.js' -import GroupFilterBar from './GroupFilterBar.jsx' import { groupLabel } from '../utils/colors.js' 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 RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx' -import ExceedanceProbability from '../charts/ExceedanceProbability.jsx' const ALL_WAVES = ['15-16', '17-18', '21-22'] +const QUADRANT_MODELS = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod'] /** - * ChartPanel — 6 charts in a single-column layout, all scoped by one shared - * student-group filter (defaults to all 8 groups selected). + * ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest + * rate by student group (Female/Male panels), and the model-selectable + * posterior density ridge chart. */ export default function ChartPanel({ district, state }) { - const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model/group - const [nationalRates, setNationalRates] = useState(null) + const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model const [loading, setLoading] = useState(true) - const filter = useGroupFilter() 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(import.meta.env.BASE_URL + 'data/national_rates.json') - nat = await res.json() - } - setNationalRates(nat) - - // Fetch all 3 waves × three-year model (unified_m3_mod) for Chart 1 + Charts 2-3 - const waves = ['21-22', '17-18', '15-16'] + // Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod) const waveData = {} - await Promise.all(waves.map(async (year) => { + await Promise.all(ALL_WAVES.map(async (year) => { try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: 'unified_m3_mod', year }) } catch (e) {} })) - // Fetch all 4 quadrant models for Charts 4-6 (8 groups × 4 models, batched by model) - const quadrants = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod'] + // Chart 3: all 4 quadrant models, so the dropdown can switch between them const quadData = {} - await Promise.all(quadrants.map(async (model) => { + await Promise.all(QUADRANT_MODELS.map(async (model) => { try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {} })) @@ -62,12 +47,11 @@ export default function ChartPanel({ district, state }) { return } - // ——— Every derived dataset below is scoped to the selected student groups ——— - const mostRecent = filterRowsByGroup(data.waveData['21-22'] || [], filter) + const mostRecent = data.waveData['21-22'] || [] // Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range const timeSeriesData = ALL_WAVES.map((year) => { - const yearRows = filterRowsByGroup(data.waveData[year] || [], filter) + const yearRows = data.waveData[year] || [] return { year, label: `20${year.replace('-', '-')}`, @@ -79,83 +63,50 @@ export default function ChartPanel({ district, state }) { } }) - // Charts 2-3: rate by student group (most recent wave, per 1k), with modeled + observed + interval - const rateByGroup = mostRecent - .map((r) => ({ - race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex), - observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000), - modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000), - rateLower: (r.rate_lower || 0) * 1000, - rateUpper: (r.rate_upper || 0) * 1000, - observedArrests: r.observed_arrests || 0, - enrollment: r.stu_enroll || 0, - })) - .sort((a, b) => b.observedRate - a.observedRate) - - const topGroup = rateByGroup[0] || null - const natKey = topGroup ? `${topGroup.race}_${topGroup.sex}` : null - const nationalTopRate = (natKey && nationalRates?.rates_per_1000?.[natKey]) || 0 - - // Charts 4-6: all 4 quadrant models, each filtered to the selected groups - const quadDataFiltered = Object.fromEntries( - Object.entries(data.quadData || {}).map(([model, rows]) => [model, filterRowsByGroup(rows, filter)]) - ) - const waveDataFiltered = Object.fromEntries( - ALL_WAVES.map((year) => [year, filterRowsByGroup(data.waveData[year] || [], filter)]) - ) + // Chart 2: rate by student group (most recent wave, per 1k), with modeled + observed + interval + const rateByGroup = mostRecent.map((r) => ({ + race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex), + observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000), + modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000), + rateLower: (r.rate_lower || 0) * 1000, + rateUpper: (r.rate_upper || 0) * 1000, + observedArrests: r.observed_arrests || 0, + enrollment: r.stu_enroll || 0, + })) return (
{/* Chart panel header */} -
+
District estimates — Bayesian model comparison

{district.lea_name} ({state}) — School-based arrest rates, 2021–22 CRDC

-
- +
+ + + + +
- {filter.selected.size === 0 ? ( -

- Select at least one student group above to see charts. -

- ) : ( -
- {/* Charts 1–3: Observed & descriptive */} - - - - - {topGroup && ( - - )} - - {/* Charts 4–6: Bayesian model distributions */} - - - - - -
- )} - {/* Methodology footer */}
Methodology

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. + the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets you explore four Bayesian model + specifications: one-year vs. three-year models with and without referral-rate covariates. All rates are + per 1,000 students.

diff --git a/src/components/GroupFilterBar.jsx b/src/components/GroupFilterBar.jsx deleted file mode 100644 index 9af3b0b..0000000 --- a/src/components/GroupFilterBar.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import { raceColor, RACE_LABELS } from '../utils/colors.js' - -const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] -const SEX_ROWS = [ - { sex: 'F', label: 'Female' }, - { sex: 'M', label: 'Male' }, -] - -/** - * Student-group filter: two rows (Female/Male) of 4 race-colored toggle - * chips. Sex is row position, not color — race owns the color channel, and - * a chip's color never changes when siblings are toggled. - */ -export default function GroupFilterBar({ filter }) { - return ( -
-
- Student groups -
- - -
-
- -
- {SEX_ROWS.map(({ sex, label }) => ( -
- {label} - {RACE_ORDER.map((race) => { - const active = filter.isSelected(race, sex) - const color = raceColor(race) - return ( - - ) - })} -
- ))} -
-
- ) -} diff --git a/src/components/LoadingAnimation.jsx b/src/components/LoadingAnimation.jsx index a329865..4fcaab0 100644 --- a/src/components/LoadingAnimation.jsx +++ b/src/components/LoadingAnimation.jsx @@ -1,26 +1,8 @@ import { useState, useEffect } from 'react' import ChartPanel from './ChartPanel.jsx' import * as api from '../hooks/useApi.js' - -// 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 }, -] +import { STUDENT_GROUPS, MODEL_QUADRANTS } from '../hooks/useApi.js' +import { shortGroupLabel } from '../utils/colors.js' // CRDC waves to fetch (for Chart 1 — time series) const WAVES = ['21-22', '17-18', '15-16'] @@ -35,11 +17,9 @@ export default function LoadingAnimation({ district, state }) { const [totalCalls, setTotalCalls] = useState(0) const [error, setError] = useState(null) - // Build the full list of API calls needed for all 6 charts + // Build the full list of API calls needed for all 3 charts useEffect(() => { let cancelled = false - let count = 0 - let total = 0 async function loadData() { try { @@ -50,14 +30,14 @@ export default function LoadingAnimation({ district, state }) { .catch(() => { /* individual failure doesn't block */ }) ) - // ——— Charts 2 & 3: Rate by group (8 groups × default model, most recent wave) ——— + // ——— Chart 2: Rate by group (8 groups × default model, most recent wave) ——— const groupPromises = STUDENT_GROUPS.map((sg) => fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex) .then(() => !cancelled && setLoadedCount(c => c + 1)) .catch(() => {}) ) - // ——— Charts 4–6: Model comparison (8 groups × 4 quadrant models = 32 calls) ——— + // ——— Chart 3: posterior density, all 4 quadrant models so the dropdown can switch (8 groups × 4 = 32 calls) ——— const modelPromises = MODEL_QUADRANTS.flatMap((quad) => STUDENT_GROUPS.map((sg) => fetchDistrictEstimatesBatch(district.leaid, '21-22', sg.race, sg.sex, quad.model) @@ -66,22 +46,16 @@ export default function LoadingAnimation({ district, state }) { ) ) - // ——— National rates (from static fixture, instant) ——— - const nationalPromise = fetch(import.meta.env.BASE_URL + '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 */) + if (!cancelled) setTotalCalls(allPromises.length) - await Promise.all([...allPromises, nationalPromise]) + await Promise.all(allPromises) if (!cancelled) { // All data loaded — ChartPanel renders in place of this component // We use a render prop pattern: return when done - setLoadedCount(allPromises.length + 1) + setLoadedCount(allPromises.length) } } catch (err) { if (!cancelled) setError(err.message || 'Failed to load data') @@ -110,10 +84,14 @@ export default function LoadingAnimation({ district, state }) { // Build bar metadata for rendering const allBars = Array.from({ length: TOTAL_BARS }, (_, i) => { if (i < WAVES.length) return { subLabel: 'Time series', group: 'waves' } - if (i < WAVES.length + STUDENT_GROUPS.length) return { subLabel: STUDENT_GROUPS[i - WAVES.length].label, group: 'groups' } + if (i < WAVES.length + STUDENT_GROUPS.length) { + const sg = STUDENT_GROUPS[i - WAVES.length] + return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'groups' } + } const quadIdx = Math.floor((i - WAVES.length - STUDENT_GROUPS.length) / STUDENT_GROUPS.length) const sgIdx = (i - WAVES.length - STUDENT_GROUPS.length) % STUDENT_GROUPS.length - return { subLabel: STUDENT_GROUPS[sgIdx].label, group: 'models', modelIdx: quadIdx } + const sg = STUDENT_GROUPS[sgIdx] + return { subLabel: shortGroupLabel(sg.race, sg.sex), group: 'models', modelIdx: quadIdx } }) return ( @@ -142,7 +120,7 @@ export default function LoadingAnimation({ district, state }) { marginTop: 'var(--space-4)' }}> {/* Column headers */} - {['Time series (Chart 1)', 'Observed rates (Charts 2–3)', 'Model comparisons (Charts 4–6)'].map((h, i) => ( + {['Time series (Chart 1)', 'By student group (Chart 2)', 'Posterior density (Chart 3)'].map((h, i) => (
{h}
diff --git a/src/data/national_rates.json b/src/data/national_rates.json deleted file mode 100644 index 65766dd..0000000 --- a/src/data/national_rates.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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 - } -} diff --git a/src/hooks/useGroupFilter.js b/src/hooks/useGroupFilter.js deleted file mode 100644 index 57d2eb9..0000000 --- a/src/hooks/useGroupFilter.js +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useCallback, useMemo } from 'react' -import { STUDENT_GROUPS } from './useApi.js' -import { groupKey } from '../utils/groupKey.js' - -/** - * Shared student-group filter — defaults to all 8 groups selected. Lives - * once in ChartPanel and threads down to every chart, so "select which - * groups to focus on" behaves consistently everywhere instead of being - * reimplemented per chart. - */ -export default function useGroupFilter(groups = STUDENT_GROUPS) { - const allKeys = useMemo(() => groups.map((g) => groupKey(g.race, g.sex)), [groups]) - const [selected, setSelected] = useState(() => new Set(allKeys)) - - const isSelected = useCallback((race, sex) => selected.has(groupKey(race, sex)), [selected]) - - const toggle = useCallback((race, sex) => { - setSelected((prev) => { - const next = new Set(prev) - const key = groupKey(race, sex) - if (next.has(key)) next.delete(key) - else next.add(key) - return next - }) - }, []) - - const selectAll = useCallback(() => setSelected(new Set(allKeys)), [allKeys]) - const selectNone = useCallback(() => setSelected(new Set()), []) - - return { groups, selected, isSelected, toggle, selectAll, selectNone, allSelected: selected.size === allKeys.length } -} - -/** Filters a rows array (each row has .race/.sex) down to the selected groups. */ -export function filterRowsByGroup(rows, filter) { - return (rows || []).filter((r) => filter.isSelected(r.race, r.sex)) -} diff --git a/src/utils/colors.js b/src/utils/colors.js index a3620cf..40ae8f8 100644 --- a/src/utils/colors.js +++ b/src/utils/colors.js @@ -20,11 +20,11 @@ export const RACE_LABELS = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'Amer // SVG viewBox. export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' } -// Mark-type colors for the observed-vs-modeled convention. These are used -// only where a chart has no race facet of its own (aggregate totals) or -// needs a neutral third "reference/benchmark" role distinct from both. +// Mark-type colors for the observed-vs-modeled convention. Used where a +// chart has no race facet of its own (aggregate totals). export const OBSERVED_MARK_COLOR = 'var(--cv-ink)' export const MODELED_AGGREGATE_COLOR = 'var(--cv-navy-600)' +// Fallback for an unrecognized race key — kept neutral rather than defaulting to a real race hue. export const REFERENCE_GRAY = 'var(--cv-ink-4)' export function raceColor(race) { diff --git a/src/utils/distributionApprox.js b/src/utils/distributionApprox.js index eb30929..fb50f5f 100644 --- a/src/utils/distributionApprox.js +++ b/src/utils/distributionApprox.js @@ -119,10 +119,5 @@ export function densityCurve(fit, { min = 0, max, n = 60 } = {}) { return points } -/** P(X > threshold) under the fitted approximation. */ -export function probabilityExceeds(fit, threshold) { - return 1 - fit.cdf(threshold) -} - export const DISTRIBUTION_APPROX_NOTE = 'Distribution shape estimated from interval bounds — not raw posterior draws.' diff --git a/src/utils/groupKey.js b/src/utils/groupKey.js deleted file mode 100644 index 20fddf9..0000000 --- a/src/utils/groupKey.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Single source of truth for the race/sex group key format (matches the - * `RACE_SEX` keys used in national_rates.json), so charts and filters agree. - */ - -export function groupKey(race, sex) { - return `${race}_${sex}` -} - -export function parseGroupKey(key) { - const [race, sex] = key.split('_') - return { race, sex } -}