Fix chart bugs found on deployed site, redesign Chart 2 as box-and-whisker
Deploy to git-pages / deploy (push) Successful in 12s
Deploy to git-pages / deploy (push) Successful in 12s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<g key={year}>
|
||||
<text x={x} y={height - 6} textAnchor="middle" fontSize="0.55rem" fill="var(--cv-ink-3)">
|
||||
<text x={tickX} y={height - 6} textAnchor={tickAnchor} fontSize="0.55rem" fill="var(--cv-ink-3)">
|
||||
{WAVE_LABELS[year]}
|
||||
</text>
|
||||
|
||||
{yearRow && (
|
||||
{agg && (
|
||||
<>
|
||||
<line
|
||||
x1={x} y1={yScale(yearRow.count_upper ?? yearRow.count_median ?? 0)}
|
||||
x2={x} y2={yScale(yearRow.count_lower ?? yearRow.count_median ?? 0)}
|
||||
x1={x} y1={yScale(agg.upper)}
|
||||
x2={x} y2={yScale(agg.lower)}
|
||||
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5}
|
||||
/>
|
||||
<circle cx={x} cy={yScale(yearRow.count_median ?? 0)} r={3.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1} />
|
||||
<circle cx={x} cy={yScale(agg.median)} r={3.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||
Arrest rate by student group — 2021–22 (per 1,000)
|
||||
</h3>
|
||||
<ApproxNote />
|
||||
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={bodyHeight} fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{[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 (
|
||||
<g key={`xg-${frac}`}>
|
||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
@@ -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 (
|
||||
<g key={`${d.race}-${d.sex}`}>
|
||||
<rect x={margin.left} y={rowY + 3} width={modeledWidth} height={ROW_HEIGHT - 6} fill={color} rx={2} />
|
||||
{/* Whisker: the API's reported 90% interval */}
|
||||
<line x1={xScale(box.lower)} y1={midY} x2={xScale(box.upper)} y2={midY} stroke={color} strokeWidth={1.5} />
|
||||
<line x1={xScale(box.lower)} y1={boxTop} x2={xScale(box.lower)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
||||
<line x1={xScale(box.upper)} y1={boxTop} x2={xScale(box.upper)} y2={boxBottom} stroke={color} strokeWidth={1.5} />
|
||||
{/* Box: fitted 25th-75th percentile */}
|
||||
<rect
|
||||
x={xScale(box.q1)} y={boxTop} width={Math.max(xScale(box.q3) - xScale(box.q1), 1)} height={boxBottom - boxTop}
|
||||
fill={color} fillOpacity={0.55} stroke={color} strokeWidth={1} rx={1.5}
|
||||
/>
|
||||
{/* Median tick */}
|
||||
<line x1={xScale(box.median)} y1={boxTop} x2={xScale(box.median)} y2={boxBottom} stroke="#fff" strokeWidth={1.5} />
|
||||
{/* Observed diamond */}
|
||||
<rect
|
||||
x={observedX - 4} y={midY - 4} width={8} height={8}
|
||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
||||
@@ -104,8 +141,8 @@ export default function RateByGroupBar({ data }) {
|
||||
]} />
|
||||
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user