From cf8e65bb8ea0a3fb76c9d7b4daaa536b36f65e00 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 10 Aug 2026 18:58:35 -0400 Subject: [PATCH] Fix chart bugs found on deployed site, redesign Chart 2 as box-and-whisker Chart 4 (model predictions vs. observed) was picking the first row for a given wave/model instead of summing across selected groups, so any district whose first-returned group had zero counts (e.g. a suppressed race group) showed a collapsed, flat modeled marker instead of the real aggregate interval. Also fixes the rightmost wave label ("2021-22") clipping off the edge of the small-multiple panels by anchoring edge ticks away from the viewBox boundary instead of centering on it. Chart 6's y-axis group labels (e.g. "American Indian / Alaska Native Female") were wider than their margin and clipped past the left edge of the SVG. Adds a shared shortGroupLabel() to colors.js and uses it here, matching the convention already used in charts 2 and 5. Chart 2 previously drew a plain bar to the modeled median next to an observed diamond that often sat well past the bar's end, reading as if the bar itself were an uncertainty range when it wasn't. Replaces it with an actual horizontal box-and-whisker: whisker = the API's reported 90% interval, box = the fitted approximation's 25th/75th percentiles (via a new fit.quantile() inverse-CDF, exact round-trip of fitSkewedInterval's own construction), median tick, observed diamond overlaid. Co-Authored-By: Claude Sonnet 5 --- src/charts/ExceedanceProbability.jsx | 6 +-- src/charts/ModelDrawsComparison.jsx | 39 +++++++++++++---- src/charts/RateByGroupBar.jsx | 65 ++++++++++++++++++++++------ src/charts/RateDensityRidgeline.jsx | 3 +- src/utils/colors.js | 9 ++++ src/utils/distributionApprox.js | 8 +++- 6 files changed, 101 insertions(+), 29 deletions(-) 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. */