diff --git a/src/charts/ExceedanceProbability.jsx b/src/charts/ExceedanceProbability.jsx index 6f35964..e980189 100644 --- a/src/charts/ExceedanceProbability.jsx +++ b/src/charts/ExceedanceProbability.jsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { groupKey } from '../utils/groupKey.js' -import { groupLabel } from '../utils/colors.js' +import { shortGroupLabel } from '../utils/colors.js' import { fitSkewedInterval, probabilityExceeds } from '../utils/distributionApprox.js' import ApproxNote from '../components/ApproxNote.jsx' @@ -29,13 +29,13 @@ export default function ExceedanceProbability({ quadData, nationalRates }) { 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: groupLabel(row.race, row.sex), nationalRate, modeledRate: rateMedian, probExceeds } + 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: 130 } + const margin = { top: 50, right: 100, bottom: 60, left: 90 } const innerWidth = width - margin.left - margin.right const plotHeight = height - margin.top - margin.bottom diff --git a/src/charts/ModelDrawsComparison.jsx b/src/charts/ModelDrawsComparison.jsx index 106e5f8..e867c15 100644 --- a/src/charts/ModelDrawsComparison.jsx +++ b/src/charts/ModelDrawsComparison.jsx @@ -13,6 +13,20 @@ import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js 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]) || [] @@ -22,9 +36,12 @@ export default function ModelDrawsComparison({ data, waves = ALL_WAVES, district const quadModels = (data && data.quadData) || {} const allValues = waveData.map((d) => d.arrests) - Object.values(quadModels).forEach((rows) => { - ;(rows || []).forEach((r) => { - allValues.push(r.count_median || 0, r.count_upper || 0) + 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 @@ -88,23 +105,27 @@ function QuadrantPanel({ q, waveData, quadModels, maxArrests }) { {showYears.map((year, i) => { const x = xScale(i) const obs = waveData.find((d) => d.year === year)?.arrests ?? 0 - const yearRow = (quadModels[q.model] || []).find((r) => r.year === year) + 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]} - {yearRow && ( + {agg && ( <> - + )} diff --git a/src/charts/RateByGroupBar.jsx b/src/charts/RateByGroupBar.jsx index bf32a86..60456e6 100644 --- a/src/charts/RateByGroupBar.jsx +++ b/src/charts/RateByGroupBar.jsx @@ -1,18 +1,38 @@ import ChartLegend from '../components/ChartLegend.jsx' -import { raceColor, OBSERVED_MARK_COLOR } from '../utils/colors.js' +import ApproxNote from '../components/ApproxNote.jsx' +import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js' +import { fitSkewedInterval } from '../utils/distributionApprox.js' /** - * Chart 2: Arrest rate per 1,000 students by student group (most recent year). - * Bar = modeled median rate, colored by race. Dark diamond = observed rate, - * overlaid at its position on the same bar. Sex is row grouping, not color. + * 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. */ const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] -const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' } const SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] -const ROW_HEIGHT = 22 +const ROW_HEIGHT = 26 const SUBHEADER_HEIGHT = 20 +function buildBox(d) { + const median = Math.max(d.modeledMedian || 0, 0) + const lower = Math.max(Math.min(d.rateLower ?? median, median), 0) + const upper = Math.max(d.rateUpper ?? median, median) + const fit = fitSkewedInterval({ median, lower, upper }) + return { + lower, + upper, + median, + q1: Math.max(fit.quantile(0.25), 0), + q3: Math.max(fit.quantile(0.75), median), + } +} + export default function RateByGroupBar({ data }) { const width = 360 const margin = { top: 40, right: 30, bottom: 45, left: 90 } @@ -29,8 +49,11 @@ export default function RateByGroupBar({ data }) { 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.modeledMedian || 0)), 0.5) - const xScale = (val) => (val / maxRate) * innerWidth + 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 @@ -39,13 +62,14 @@ export default function RateByGroupBar({ data }) {

Arrest rate by student group — 2021–22 (per 1,000)

+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => { const val = frac * maxRate - const x = margin.left + xScale(val) + const x = xScale(val) return ( @@ -63,13 +87,26 @@ export default function RateByGroupBar({ data }) { const rowY = cursorY cursorY += ROW_HEIGHT const color = raceColor(d.race) - const modeledWidth = xScale(d.modeledMedian || 0) - const observedX = margin.left + xScale(d.observedRate) + 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 */}

- Bar length is the Bayesian model's median estimate (unified_m2_mod); the dark marker is the - observed rate at that position. + Box = modeled 25th–75th percentile (fitted approximation); whisker = the model's reported + 90% interval; white tick = median. The dark diamond is the observed rate.

) diff --git a/src/charts/RateDensityRidgeline.jsx b/src/charts/RateDensityRidgeline.jsx index 723c0d0..3978f08 100644 --- a/src/charts/RateDensityRidgeline.jsx +++ b/src/charts/RateDensityRidgeline.jsx @@ -1,5 +1,5 @@ import { MODEL_QUADRANTS } from '../hooks/useApi.js' -import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS } from '../utils/colors.js' +import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js' import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js' import ChartLegend from '../components/ChartLegend.jsx' import ApproxNote from '../components/ApproxNote.jsx' @@ -14,7 +14,6 @@ import ApproxNote from '../components/ApproxNote.jsx' const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] const SEX_ORDER = ['F', 'M'] -const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' } function buildGroupRow(row) { const enroll = row.stu_enroll || 0 diff --git a/src/utils/colors.js b/src/utils/colors.js index d288d17..a3620cf 100644 --- a/src/utils/colors.js +++ b/src/utils/colors.js @@ -15,6 +15,11 @@ export const RACE_COLORS = { WH: '#3D6FC4', BL: '#C98A2A', HI: '#9C3F86', AM: '# export const RACE_LABELS = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'American Indian / Alaska Native' } +// Compact form for axis ticks and other space-constrained labels, where the +// full RACE_LABELS text would overflow its container and get clipped by the +// SVG viewBox. +export const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' } + // Mark-type colors for the observed-vs-modeled convention. 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. @@ -29,3 +34,7 @@ export function raceColor(race) { export function groupLabel(race, sex) { return `${RACE_LABELS[race] || race} ${sex === 'F' ? 'Female' : 'Male'}` } + +export function shortGroupLabel(race, sex) { + return `${SHORT_RACE_LABEL[race] || race} ${sex}` +} diff --git a/src/utils/distributionApprox.js b/src/utils/distributionApprox.js index 468e513..eb30929 100644 --- a/src/utils/distributionApprox.js +++ b/src/utils/distributionApprox.js @@ -97,7 +97,13 @@ export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.90, f return standardNormalCdf((x - median) / sigma) } - return { median, sigmaLeft, sigmaRight, pdf, cdf } + /** Inverse cdf — exact round-trip of fitSkewedInterval's own construction (quantile(fit, 0.05) === lower, etc). */ + function quantile(p) { + const z = probit(p) + return median + (p <= 0.5 ? sigmaLeft : sigmaRight) * z + } + + return { median, sigmaLeft, sigmaRight, pdf, cdf, quantile } } /** n evenly spaced {x,y} points of the fitted pdf, for drawing a smooth curve. */