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 { useState } from 'react'
|
||||||
import { groupKey } from '../utils/groupKey.js'
|
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 { fitSkewedInterval, probabilityExceeds } from '../utils/distributionApprox.js'
|
||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
import ApproxNote from '../components/ApproxNote.jsx'
|
||||||
|
|
||||||
@@ -29,13 +29,13 @@ export default function ExceedanceProbability({ quadData, nationalRates }) {
|
|||||||
const rateUpper = (row.rate_upper || 0) * 1000
|
const rateUpper = (row.rate_upper || 0) * 1000
|
||||||
const fit = fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper })
|
const fit = fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper })
|
||||||
const probExceeds = Math.max(0.01, Math.min(0.99, probabilityExceeds(fit, nationalRate)))
|
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)
|
.sort((a, b) => b.probExceeds - a.probExceeds)
|
||||||
|
|
||||||
const width = 400
|
const width = 400
|
||||||
const height = 320
|
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 innerWidth = width - margin.left - margin.right
|
||||||
const plotHeight = height - margin.top - margin.bottom
|
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 WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
|
||||||
const ALL_WAVES = ['15-16', '17-18', '21-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 }) {
|
export default function ModelDrawsComparison({ data, waves = ALL_WAVES, districtName }) {
|
||||||
const waveData = waves.map((year) => {
|
const waveData = waves.map((year) => {
|
||||||
const rows = (data.waves && data.waves[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 quadModels = (data && data.quadData) || {}
|
||||||
|
|
||||||
const allValues = waveData.map((d) => d.arrests)
|
const allValues = waveData.map((d) => d.arrests)
|
||||||
Object.values(quadModels).forEach((rows) => {
|
Object.entries(quadModels).forEach(([model, rows]) => {
|
||||||
;(rows || []).forEach((r) => {
|
const isThreeYear = model.includes('m3') || model.includes('m4')
|
||||||
allValues.push(r.count_median || 0, r.count_upper || 0)
|
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
|
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) => {
|
{showYears.map((year, i) => {
|
||||||
const x = xScale(i)
|
const x = xScale(i)
|
||||||
const obs = waveData.find((d) => d.year === year)?.arrests ?? 0
|
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)
|
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 (
|
return (
|
||||||
<g key={year}>
|
<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]}
|
{WAVE_LABELS[year]}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{yearRow && (
|
{agg && (
|
||||||
<>
|
<>
|
||||||
<line
|
<line
|
||||||
x1={x} y1={yScale(yearRow.count_upper ?? yearRow.count_median ?? 0)}
|
x1={x} y1={yScale(agg.upper)}
|
||||||
x2={x} y2={yScale(yearRow.count_lower ?? yearRow.count_median ?? 0)}
|
x2={x} y2={yScale(agg.lower)}
|
||||||
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5}
|
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 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).
|
* Chart 2: Arrest rate per 1,000 students by student group (most recent
|
||||||
* Bar = modeled median rate, colored by race. Dark diamond = observed rate,
|
* year). A plain bar to the modeled median (next to an observed diamond
|
||||||
* overlaid at its position on the same bar. Sex is row grouping, not color.
|
* 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 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 SEX_ROWS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }]
|
||||||
const ROW_HEIGHT = 22
|
const ROW_HEIGHT = 26
|
||||||
const SUBHEADER_HEIGHT = 20
|
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 }) {
|
export default function RateByGroupBar({ data }) {
|
||||||
const width = 360
|
const width = 360
|
||||||
const margin = { top: 40, right: 30, bottom: 45, left: 90 }
|
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 bodyHeight = bySex.reduce((sum, g) => sum + SUBHEADER_HEIGHT + g.rows.length * ROW_HEIGHT, 0)
|
||||||
const height = margin.top + bodyHeight + margin.bottom
|
const height = margin.top + bodyHeight + margin.bottom
|
||||||
|
|
||||||
const maxRate = Math.max(...data.map((d) => Math.max(d.observedRate, d.modeledMedian || 0)), 0.5)
|
const maxRate = Math.max(
|
||||||
const xScale = (val) => (val / maxRate) * innerWidth
|
...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
|
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)' }}>
|
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
|
||||||
Arrest rate by student group — 2021–22 (per 1,000)
|
Arrest rate by student group — 2021–22 (per 1,000)
|
||||||
</h3>
|
</h3>
|
||||||
|
<ApproxNote />
|
||||||
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%' }}>
|
<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} />
|
<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) => {
|
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||||
const val = frac * maxRate
|
const val = frac * maxRate
|
||||||
const x = margin.left + xScale(val)
|
const x = xScale(val)
|
||||||
return (
|
return (
|
||||||
<g key={`xg-${frac}`}>
|
<g key={`xg-${frac}`}>
|
||||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
<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
|
const rowY = cursorY
|
||||||
cursorY += ROW_HEIGHT
|
cursorY += ROW_HEIGHT
|
||||||
const color = raceColor(d.race)
|
const color = raceColor(d.race)
|
||||||
const modeledWidth = xScale(d.modeledMedian || 0)
|
const box = buildBox(d)
|
||||||
const observedX = margin.left + xScale(d.observedRate)
|
|
||||||
const midY = rowY + ROW_HEIGHT / 2
|
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 (
|
return (
|
||||||
<g key={`${d.race}-${d.sex}`}>
|
<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
|
<rect
|
||||||
x={observedX - 4} y={midY - 4} width={8} height={8}
|
x={observedX - 4} y={midY - 4} width={8} height={8}
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1}
|
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)' }}>
|
<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
|
Box = modeled 25th–75th percentile (fitted approximation); whisker = the model's reported
|
||||||
observed rate at that position.
|
90% interval; white tick = median. The dark diamond is the observed rate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
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 { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
|
||||||
import ChartLegend from '../components/ChartLegend.jsx'
|
import ChartLegend from '../components/ChartLegend.jsx'
|
||||||
import ApproxNote from '../components/ApproxNote.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 RACE_ORDER = ['WH', 'BL', 'HI', 'AM']
|
||||||
const SEX_ORDER = ['F', 'M']
|
const SEX_ORDER = ['F', 'M']
|
||||||
const SHORT_RACE_LABEL = { WH: 'White', BL: 'Black', HI: 'Hispanic', AM: 'AI/AN' }
|
|
||||||
|
|
||||||
function buildGroupRow(row) {
|
function buildGroupRow(row) {
|
||||||
const enroll = row.stu_enroll || 0
|
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' }
|
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
|
// 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
|
// only where a chart has no race facet of its own (aggregate totals) or
|
||||||
// needs a neutral third "reference/benchmark" role distinct from both.
|
// needs a neutral third "reference/benchmark" role distinct from both.
|
||||||
@@ -29,3 +34,7 @@ export function raceColor(race) {
|
|||||||
export function groupLabel(race, sex) {
|
export function groupLabel(race, sex) {
|
||||||
return `${RACE_LABELS[race] || race} ${sex === 'F' ? 'Female' : 'Male'}`
|
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 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. */
|
/** n evenly spaced {x,y} points of the fitted pdf, for drawing a smooth curve. */
|
||||||
|
|||||||
Reference in New Issue
Block a user