Ports Figs 6 and 7 from crdc-arrests/R/paper_figures.R (wp_fig_group_density and
wp_fig_group_difference) so someone who has read the paper sees the paper. The
three old charts become a summary table and two charts; ArrestsOverTime stays.
Draws pipeline. useDrawDistribution now selects draw_id and returns predicted
counts indexed by draw rather than pre-divided rates. That one column is what
unlocks the rest: pooling has to sum numerators and denominators separately, and
a between-group difference has to be taken at a common draw index. Indexing by
draw_id rather than push order makes DuckDB's row ordering irrelevant and turns
a missing draw into a hole, which isCompleteDrawSet then rejects — a group
present for 300 of 500 draws would otherwise get an interval computed off a
biased subsample that looks identical on screen to a complete one. It also takes
models[] instead of a single model, so "compare all four" needs no conditional
hooks. The reset-before-guard ordering is preserved.
Also fixes a pre-existing bug in that pipeline: a state's draws are split across
data_0.parquet, data_1.parquet, … and the part count varies by state. The app
only ever fetched data_0. Nevada has one part, so this was invisible in every
Nevada test; California has eight, totalling 6.2MB, of which data_0 is 37KB and
holds 11 of California's 1,715 districts. Every other CA district looked absent
from the published data and silently fell back to the approximation. Parts are
now discovered from the Hugging Face tree listing API and fetched in parallel —
listing rather than probing data_N until a 404, because the browser logs a 404
as a console error however cleanly the fetch handles it, and a red error on
every load is indistinguishable from a real one. HEAD probing is the fallback.
New pure utils, all written against tests first:
agrestiCoull faithful port incl. the zero-numerator rule of three and the
negative lower bound at (1, 53); pinned to five R outputs
pooling sex pooling for sparse districts; numerator and denominator
are always drawn from the same set of groups
densityProfile discrete probability mass below 12 distinct values, KDE above;
KDE delegates to kde.js, whose bandwidth clamp is untouched
districtGroups display-row derivation, defaults, pooled vs unpooled keys
groupDifference per-draw delta; refuses to pair mismatched draw sets
rateDomain shared x-axis, with a clip flag so the cap is never silent
Chart A keeps the palette contract: race is hue, sex is position. Female and
Male are stacked panels sharing one axis, collapsing to one panel when pooled —
never a second hue. Chart B uses a diverging ramp centred at zero rather than
the paper's sequential YlOrRd, because delta is signed and a sequential ramp
encodes "more" where the data means "which direction".
Captions say "posterior predictive draws", never "paired parameter draws":
draw_id is renumbered per write batch upstream and a district's groups land in
different batches, so cross-group pairing is effectively independent (measured
cor ~= 0.02). The published figure has the same property; what neither can claim
is a paired-parameter contrast.
Sparse districts (under 20 arrests district-wide) pool Female and Male within
each race, with a banner stating the rule and a switch to override it. A pooled
group carries no modelled interval — summing two groups' interval bounds is not
a pooled interval, and there is no honest way to fake one without the draws.
React still owns the DOM. d3-scale/shape/array/interpolate supply scales, path
generators and colour interpolation; no selections, no useEffect DOM mutation.
Deletes RateByGroupBar and RateDensityRidgeline. Keeps distributionApprox.js and
ApproxNote.jsx — still the per-group fallback when draws can't be fetched.
112 tests pass; npm run build clean.
235 lines
8.0 KiB
React
235 lines
8.0 KiB
React
import { raceColor } from '../utils/colors.js'
|
||
import { POOL_BY_SEX_ARREST_THRESHOLD } from '../utils/pooling.js'
|
||
|
||
/**
|
||
* The results page's first block: what was actually observed, before any
|
||
* modelling. One row per student group plus a district total.
|
||
*
|
||
* The table doubles as the legend and the control for the density panel — each
|
||
* row carries the checkbox that adds or removes that group's curve, and the
|
||
* colour swatch is the same hue the curve is drawn in. That's deliberate:
|
||
* a separate legend plus a separate group picker would make the reader hold
|
||
* three mappings in their head instead of one.
|
||
*
|
||
* @param {{
|
||
* groups: Array<{key:string, race:string, sex:string|null, label:string,
|
||
* enroll:number, observed:number, rate:number}>,
|
||
* selectedKeys: string[],
|
||
* onToggleGroup: (key: string) => void,
|
||
* pooled: boolean,
|
||
* autoPooled: boolean,
|
||
* onPooledChange: (pooled: boolean) => void,
|
||
* selectionIsEnrollmentFallback: boolean,
|
||
* }} props
|
||
*/
|
||
export default function DistrictSummaryTable({
|
||
groups,
|
||
selectedKeys,
|
||
onToggleGroup,
|
||
pooled,
|
||
autoPooled,
|
||
onPooledChange,
|
||
selectionIsEnrollmentFallback,
|
||
}) {
|
||
if (!groups?.length) {
|
||
return (
|
||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||
<h3 style={cardTitle}>Reported arrests and enrollment</h3>
|
||
<p style={{ color: 'var(--cv-ink-3)', margin: 0 }}>
|
||
No student-group data is available for this district in 2021–22.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const selected = new Set(selectedKeys)
|
||
const totalEnroll = groups.reduce((sum, g) => sum + g.enroll, 0)
|
||
const totalObserved = groups.reduce((sum, g) => sum + g.observed, 0)
|
||
const totalRate = totalEnroll > 0 ? (totalObserved / totalEnroll) * 1000 : 0
|
||
|
||
return (
|
||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||
<h3 style={cardTitle}>Reported arrests and enrollment, 2021–22</h3>
|
||
<p style={{ fontSize: '0.8rem', color: 'var(--cv-ink-3)', margin: '0 0 var(--space-2)' }}>
|
||
Check a group to show its modelled arrest-rate distribution in the chart below.
|
||
</p>
|
||
|
||
{pooled && (
|
||
<PoolingBanner autoPooled={autoPooled} totalObserved={totalObserved} onPooledChange={onPooledChange} />
|
||
)}
|
||
|
||
{selectionIsEnrollmentFallback && (
|
||
<p style={noteStyle}>
|
||
No group in this district reported an arrest in 2021–22, so the two largest groups by
|
||
enrollment are shown by default.
|
||
</p>
|
||
)}
|
||
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={tableStyle}>
|
||
<caption style={{ captionSide: 'bottom', textAlign: 'left', paddingTop: 'var(--space-1)', fontSize: '0.75rem', color: 'var(--cv-ink-3)' }}>
|
||
Students counted are those in the four modelled race groups (American Indian / Alaska
|
||
Native, Black, Hispanic, White) — not the district’s total enrollment, which also
|
||
includes groups this model does not estimate.
|
||
</caption>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" style={{ ...thStyle, textAlign: 'left' }}>Student group</th>
|
||
<th scope="col" style={thStyle}>Students</th>
|
||
<th scope="col" style={thStyle}>Observed arrests</th>
|
||
<th scope="col" style={thStyle}>Rate per 1,000</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groups.map((g) => (
|
||
<GroupRow
|
||
key={g.key}
|
||
group={g}
|
||
checked={selected.has(g.key)}
|
||
onToggle={() => onToggleGroup(g.key)}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<th scope="row" style={{ ...tdStyle, textAlign: 'left', fontWeight: 700, borderTop: '2px solid var(--cv-ink)' }}>
|
||
All modelled groups
|
||
</th>
|
||
<td style={{ ...numStyle, fontWeight: 700, borderTop: '2px solid var(--cv-ink)' }}>
|
||
{totalEnroll.toLocaleString()}
|
||
</td>
|
||
<td style={{ ...numStyle, fontWeight: 700, borderTop: '2px solid var(--cv-ink)' }}>
|
||
{totalObserved.toLocaleString()}
|
||
</td>
|
||
<td style={{ ...numStyle, fontWeight: 700, borderTop: '2px solid var(--cv-ink)' }}>
|
||
{formatRate(totalRate)}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
{!pooled && (
|
||
<label style={{ ...toggleLabel, marginTop: 'var(--space-2)' }}>
|
||
<input type="checkbox" checked={false} onChange={() => onPooledChange(true)} />
|
||
<span>Combine Female and Male within each race</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GroupRow({ group, checked, onToggle }) {
|
||
const zero = group.observed === 0
|
||
const inputId = `group-toggle-${group.key}`
|
||
return (
|
||
<tr style={{ opacity: zero ? 0.62 : 1 }}>
|
||
<td style={{ ...tdStyle, textAlign: 'left' }}>
|
||
<label htmlFor={inputId} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||
<input id={inputId} type="checkbox" checked={checked} onChange={onToggle} />
|
||
<span
|
||
aria-hidden="true"
|
||
style={{
|
||
width: '11px',
|
||
height: '11px',
|
||
borderRadius: '3px',
|
||
flexShrink: 0,
|
||
// Hollow when deselected: the swatch tracks whether that curve is
|
||
// on screen, so the table stays a truthful legend.
|
||
background: checked ? raceColor(group.race) : 'transparent',
|
||
border: `2px solid ${raceColor(group.race)}`,
|
||
}}
|
||
/>
|
||
<span>{group.label}</span>
|
||
</label>
|
||
</td>
|
||
<td style={numStyle}>{group.enroll.toLocaleString()}</td>
|
||
<td style={numStyle}>{group.observed.toLocaleString()}</td>
|
||
<td style={numStyle}>{formatRate(group.rate)}</td>
|
||
</tr>
|
||
)
|
||
}
|
||
|
||
function PoolingBanner({ autoPooled, totalObserved, onPooledChange }) {
|
||
return (
|
||
<div style={bannerStyle}>
|
||
<p style={{ margin: 0, fontSize: '0.82rem' }}>
|
||
{autoPooled ? (
|
||
<>
|
||
This district reported {totalObserved.toLocaleString()} arrests in total — fewer than{' '}
|
||
{POOL_BY_SEX_ARREST_THRESHOLD} — so Female and Male students are combined within each
|
||
race to give each estimate more data to stand on.
|
||
</>
|
||
) : (
|
||
<>Female and Male students are combined within each race.</>
|
||
)}
|
||
</p>
|
||
<label style={toggleLabel}>
|
||
<input type="checkbox" checked onChange={() => onPooledChange(false)} />
|
||
<span>Combined — uncheck to show Female and Male separately</span>
|
||
</label>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function formatRate(rate) {
|
||
if (!(rate > 0)) return '0.0'
|
||
return rate.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||
}
|
||
|
||
const cardTitle = { fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }
|
||
|
||
const tableStyle = {
|
||
width: '100%',
|
||
borderCollapse: 'collapse',
|
||
fontSize: '0.85rem',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}
|
||
|
||
const thStyle = {
|
||
textAlign: 'right',
|
||
padding: '0.35rem 0.5rem',
|
||
borderBottom: '1px solid var(--cv-rule-strong)',
|
||
fontSize: '0.72rem',
|
||
fontWeight: 600,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.06em',
|
||
color: 'var(--cv-ink-3)',
|
||
whiteSpace: 'nowrap',
|
||
}
|
||
|
||
const tdStyle = {
|
||
padding: '0.35rem 0.5rem',
|
||
borderBottom: '1px solid var(--cv-rule)',
|
||
}
|
||
|
||
const numStyle = { ...tdStyle, textAlign: 'right', whiteSpace: 'nowrap' }
|
||
|
||
const bannerStyle = {
|
||
background: 'var(--cv-paper-2)',
|
||
border: '1px solid var(--cv-rule)',
|
||
borderLeft: '3px solid var(--cv-accent)',
|
||
borderRadius: 'var(--radius-md)',
|
||
padding: 'var(--space-1) var(--space-2)',
|
||
marginBottom: 'var(--space-2)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.4rem',
|
||
}
|
||
|
||
const toggleLabel = {
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '0.45rem',
|
||
fontSize: '0.78rem',
|
||
color: 'var(--cv-ink-2)',
|
||
cursor: 'pointer',
|
||
}
|
||
|
||
const noteStyle = {
|
||
fontSize: '0.78rem',
|
||
fontStyle: 'italic',
|
||
color: 'var(--cv-ink-3)',
|
||
margin: '0 0 var(--space-2)',
|
||
}
|