Compare commits
2
Commits
4396a56a4a
...
fd7acddeb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd7acddeb2
|
||
|
|
c8757cd786
|
@@ -14,6 +14,19 @@ import { MODEL_QUADRANT_LABEL } from '../utils/labels.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 HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it
|
const HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it
|
||||||
|
|
||||||
|
// Matches RateDensityPanel/GroupDifference. These SVGs are rendered at
|
||||||
|
// width="100%" inside the same card, so the viewBox width sets the scale factor
|
||||||
|
// for everything in it: at 360 this chart's 0.7rem text came out roughly twice
|
||||||
|
// the size of the other charts' 0.62rem, which is what made it look like a
|
||||||
|
// different chart from a different app.
|
||||||
|
const WIDTH = 760
|
||||||
|
const HEIGHT = 300
|
||||||
|
const MARGIN = { top: 20, right: 24, bottom: 52, left: 56 }
|
||||||
|
// Keeps the first and last wave off the plot edges. Without it the end markers
|
||||||
|
// sit flush against the y-axis and the right border, so half of each diamond's
|
||||||
|
// glyph is visually clipped and its label has nowhere to go.
|
||||||
|
const X_PAD = 64
|
||||||
|
|
||||||
export default function ArrestsOverTime({ data, modelId }) {
|
export default function ArrestsOverTime({ data, modelId }) {
|
||||||
const maxArrests = Math.max(
|
const maxArrests = Math.max(
|
||||||
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
|
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
|
||||||
@@ -22,13 +35,15 @@ export default function ArrestsOverTime({ data, modelId }) {
|
|||||||
const { ticks, niceMax } = niceTicks(maxArrests)
|
const { ticks, niceMax } = niceTicks(maxArrests)
|
||||||
const modelLabel = MODEL_QUADRANT_LABEL[modelId] || modelId
|
const modelLabel = MODEL_QUADRANT_LABEL[modelId] || modelId
|
||||||
|
|
||||||
const width = 360
|
const width = WIDTH
|
||||||
const height = 280
|
const height = HEIGHT
|
||||||
const margin = { top: 40, right: 40, bottom: 60, left: 65 }
|
const margin = MARGIN
|
||||||
const innerWidth = width - margin.left - margin.right
|
const innerWidth = width - margin.left - margin.right
|
||||||
const innerHeight = height - margin.top - margin.bottom
|
const innerHeight = height - margin.top - margin.bottom
|
||||||
|
|
||||||
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
|
const plotLeft = margin.left + X_PAD
|
||||||
|
const plotRight = margin.left + innerWidth - X_PAD
|
||||||
|
const xScale = (i) => plotLeft + (i / Math.max(data.length - 1, 1)) * (plotRight - plotLeft)
|
||||||
const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax)
|
const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,48 +57,55 @@ export default function ArrestsOverTime({ data, modelId }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxWidth: '100%', marginTop: 'var(--space-1)' }}>
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`}
|
||||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
|
style={{ maxWidth: '100%', minWidth: '320px', marginTop: 'var(--space-1)' }}
|
||||||
fill="var(--cv-paper-2)" rx={4} />
|
role="img" aria-label="Total arrests by CRDC wave, observed against the modeled total">
|
||||||
|
|
||||||
{ticks.map((val) => {
|
{ticks.map((val) => {
|
||||||
const y = margin.top + yScale(val)
|
const y = margin.top + yScale(val)
|
||||||
return (
|
return (
|
||||||
<g key={`y-${val}`}>
|
<g key={`y-${val}`}>
|
||||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
|
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
|
||||||
stroke="var(--cv-rule)" strokeWidth={1} />
|
stroke="var(--cv-rule)" strokeWidth={1} />
|
||||||
<text x={margin.left - 8} y={y + 4} textAnchor="end"
|
<text x={margin.left - 8} y={y + 3} textAnchor="end"
|
||||||
fontSize="0.7rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
|
fontSize="0.62rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<text x={15} y={margin.top + innerHeight / 2} textAnchor="middle"
|
{/* Baseline, so the wave labels read as sitting on an axis */}
|
||||||
fontSize="0.7rem" fill="var(--cv-ink-3)" transform={`rotate(-90 15 ${margin.top + innerHeight / 2})`}>
|
<line x1={margin.left} y1={margin.top + yScale(0)} x2={margin.left + innerWidth}
|
||||||
Total arrests (count)
|
y2={margin.top + yScale(0)} stroke="var(--cv-rule-strong)" strokeWidth={1} />
|
||||||
|
|
||||||
|
<text x={14} y={margin.top + innerHeight / 2} textAnchor="middle"
|
||||||
|
fontSize="0.64rem" fill="var(--cv-ink-3)" transform={`rotate(-90 14 ${margin.top + innerHeight / 2})`}>
|
||||||
|
Total arrests
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{data.map((d, i) => (
|
{data.map((d, i) => (
|
||||||
<text key={d.year} x={margin.left + xScale(i)} y={height - margin.bottom + 15}
|
<text key={d.year} x={xScale(i)} y={margin.top + yScale(0) + 18}
|
||||||
textAnchor="middle" fontSize="0.7rem" fill="var(--cv-ink)">
|
textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-2)">
|
||||||
{WAVE_LABELS[d.year] || d.label}
|
{WAVE_LABELS[d.year] || d.label}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<text x={margin.left + innerWidth / 2} y={height - 5} textAnchor="middle"
|
<text x={margin.left + innerWidth / 2} y={height - 6} textAnchor="middle"
|
||||||
fontSize="0.7rem" fill="var(--cv-ink-3)">CRDC wave</text>
|
fontSize="0.64rem" fill="var(--cv-ink-3)">CRDC wave</text>
|
||||||
|
|
||||||
{/* Modeled point-range per wave — drawn first, directly under the observed marks it pairs with */}
|
{/* Modeled point-range per wave — drawn first, directly under the
|
||||||
|
observed marks it pairs with, and kept visually subordinate (thinner,
|
||||||
|
no halo) so the observed series reads as the primary line. */}
|
||||||
{data.map((d, i) => {
|
{data.map((d, i) => {
|
||||||
if (d.modeledMedian == null) return null
|
if (d.modeledMedian == null) return null
|
||||||
const cx = margin.left + xScale(i)
|
const cx = xScale(i)
|
||||||
const cyMedian = margin.top + yScale(d.modeledMedian)
|
const cyMedian = margin.top + yScale(d.modeledMedian)
|
||||||
const cyLower = margin.top + yScale(d.modeledLower ?? d.modeledMedian)
|
const cyLower = margin.top + yScale(d.modeledLower ?? d.modeledMedian)
|
||||||
const cyUpper = margin.top + yScale(d.modeledUpper ?? d.modeledMedian)
|
const cyUpper = margin.top + yScale(d.modeledUpper ?? d.modeledMedian)
|
||||||
return (
|
return (
|
||||||
<g key={`modeled-${d.year}`}>
|
<g key={`modeled-${d.year}`}>
|
||||||
<line x1={cx} y1={cyLower} x2={cx} y2={cyUpper} stroke={MODELED_AGGREGATE_COLOR} strokeWidth={2} />
|
<line x1={cx} y1={cyLower} x2={cx} y2={cyUpper}
|
||||||
<circle cx={cx} cy={cyMedian} r={4.5} fill={MODELED_AGGREGATE_COLOR} stroke="#fff" strokeWidth={1.25} />
|
stroke={MODELED_AGGREGATE_COLOR} strokeWidth={1.5} strokeLinecap="round" opacity={0.75} />
|
||||||
|
<circle cx={cx} cy={cyMedian} r={3.5} fill={MODELED_AGGREGATE_COLOR}
|
||||||
|
stroke="var(--cv-paper)" strokeWidth={1.25} />
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -91,37 +113,31 @@ export default function ArrestsOverTime({ data, modelId }) {
|
|||||||
{/* Observed line */}
|
{/* Observed line */}
|
||||||
{data.length > 1 && (
|
{data.length > 1 && (
|
||||||
<polyline
|
<polyline
|
||||||
points={data.map((d, i) => `${margin.left + xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
|
points={data.map((d, i) => `${xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
|
||||||
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2.5}
|
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2}
|
||||||
strokeLinejoin="round" strokeLinecap="round"
|
strokeLinejoin="round" strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Observed diamonds + rate-per-1k labels (halo behind the text keeps
|
{/* Observed diamonds + rate-per-1k labels. X_PAD keeps the end markers
|
||||||
it legible over the modeled whisker sharing the same column) */}
|
clear of the plot edges, so every label can be centred over its own
|
||||||
|
point instead of being anchored outward to avoid an overflow. */}
|
||||||
{data.map((d, i) => {
|
{data.map((d, i) => {
|
||||||
const cx = margin.left + xScale(i)
|
const cx = xScale(i)
|
||||||
const cy = margin.top + yScale(d.arrests)
|
const cy = margin.top + yScale(d.arrests)
|
||||||
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.0'
|
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.00'
|
||||||
// The first/last points sit flush on the plot's left/right edge, so a
|
|
||||||
// center-anchored label above them would overflow into the y-axis
|
|
||||||
// ticks or off the right edge — anchor those two outward instead.
|
|
||||||
const isFirst = i === 0
|
|
||||||
const isLast = i === data.length - 1
|
|
||||||
const anchor = isFirst ? 'start' : isLast ? 'end' : 'middle'
|
|
||||||
const labelX = isFirst ? cx + 7 : isLast ? cx - 7 : cx
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={d.year}>
|
<g key={d.year}>
|
||||||
<rect x={cx - 5} y={cy - 5} width={10} height={10}
|
<rect x={cx - 4.5} y={cy - 4.5} width={9} height={9}
|
||||||
fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5}
|
fill={OBSERVED_MARK_COLOR} stroke="var(--cv-paper)" strokeWidth={1.5}
|
||||||
transform={`rotate(45 ${cx} ${cy})`} />
|
transform={`rotate(45 ${cx} ${cy})`} />
|
||||||
<text x={labelX} y={cy - 16} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
|
<text x={cx} y={cy - 13} textAnchor="middle" fontSize="0.66rem" fontWeight={700}
|
||||||
fill="var(--cv-ink)" stroke="var(--cv-paper-2)" strokeWidth={3}
|
fill="var(--cv-ink)" stroke="var(--cv-paper)" strokeWidth={3.5}
|
||||||
strokeLinejoin="round" paintOrder="stroke">{ratePerK}</text>
|
strokeLinejoin="round" paintOrder="stroke">
|
||||||
<text x={labelX} y={cy - 7} textAnchor={anchor} fontSize="0.6rem"
|
{ratePerK}
|
||||||
fill="var(--cv-ink-3)" stroke="var(--cv-paper-2)" strokeWidth={3}
|
<tspan fontSize="0.58rem" fontWeight={500} fill="var(--cv-ink-3)"> per 1,000</tspan>
|
||||||
strokeLinejoin="round" paintOrder="stroke">per 1k</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { agrestiCoull } from '../utils/agrestiCoull.js'
|
|||||||
import { densityProfile } from '../utils/densityProfile.js'
|
import { densityProfile } from '../utils/densityProfile.js'
|
||||||
import { displayDraws } from '../utils/districtGroups.js'
|
import { displayDraws } from '../utils/districtGroups.js'
|
||||||
import { computeRateDomain } from '../utils/rateDomain.js'
|
import { computeRateDomain } from '../utils/rateDomain.js'
|
||||||
|
import { layoutPeakLabels } from '../utils/labelLayout.js'
|
||||||
import { toRates } from '../utils/pooling.js'
|
import { toRates } from '../utils/pooling.js'
|
||||||
import { densityCurve, fitSkewedInterval } from '../utils/distributionApprox.js'
|
import { densityCurve, fitSkewedInterval } from '../utils/distributionApprox.js'
|
||||||
import ApproxNote from '../components/ApproxNote.jsx'
|
import ApproxNote from '../components/ApproxNote.jsx'
|
||||||
@@ -39,6 +40,19 @@ const ROW_HEIGHT = 118
|
|||||||
const COMPARE_ROW_HEIGHT = 74
|
const COMPARE_ROW_HEIGHT = 74
|
||||||
const RAIL_HEIGHT = 26
|
const RAIL_HEIGHT = 26
|
||||||
|
|
||||||
|
// Direct labels live in a reserved band above each row rather than floating at
|
||||||
|
// each curve's apex. Every density is normalized to its own peak, so all the
|
||||||
|
// apexes sit at the same height — placing labels there stacks them on one line
|
||||||
|
// and they overprint into gibberish as soon as two groups have similar rates.
|
||||||
|
const LABEL_FONT_PX = 10.4 // 0.65rem
|
||||||
|
const COMPACT_LABEL_FONT_PX = 9.3 // 0.58rem
|
||||||
|
// Comfortably above the rendered line box (a 10.4px label measures ~12.1px tall
|
||||||
|
// with descenders), so adjacent lanes clear each other instead of just touching.
|
||||||
|
const LABEL_LINE_HEIGHT = 14
|
||||||
|
const COMPACT_LABEL_LINE_HEIGHT = 12
|
||||||
|
const LABEL_GAP = 8
|
||||||
|
const SPEC_LABEL_HEIGHT = 13
|
||||||
|
|
||||||
export default function RateDensityPanel({
|
export default function RateDensityPanel({
|
||||||
groups,
|
groups,
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
@@ -142,7 +156,11 @@ export default function RateDensityPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{anyApproximated && <ApproxNote />}
|
{/* Only once the fetch has settled. While it is in flight every group is
|
||||||
|
nominally "approximated" (there are no draws yet), so showing the note
|
||||||
|
then would claim a fallback that hasn't happened — next to a spinner
|
||||||
|
saying the real draws are still coming. */}
|
||||||
|
{!loading && anyApproximated && <ApproxNote />}
|
||||||
|
|
||||||
{selected.length === 0 ? (
|
{selected.length === 0 ? (
|
||||||
<p style={emptyStyle}>
|
<p style={emptyStyle}>
|
||||||
@@ -207,6 +225,12 @@ function profileFor(group, counts, enroll, niceMax) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** x of a profile's tallest point, clamped into the plotted domain. */
|
||||||
|
function peakX(profile, x, niceMax) {
|
||||||
|
const peak = profile.points.reduce((best, p) => (p.y > best.y ? p : best), profile.points[0])
|
||||||
|
return x(Math.min(peak.x, niceMax))
|
||||||
|
}
|
||||||
|
|
||||||
function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, compareAll }) {
|
function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, compareAll }) {
|
||||||
const rows = activeModels.map((model) => ({
|
const rows = activeModels.map((model) => ({
|
||||||
model,
|
model,
|
||||||
@@ -217,11 +241,35 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
|
|||||||
const hasAnything = rows.some((r) => r.entries.some((e) => e.profile))
|
const hasAnything = rows.some((r) => r.entries.some((e) => e.profile))
|
||||||
const rowHeight = compareAll ? COMPARE_ROW_HEIGHT : ROW_HEIGHT
|
const rowHeight = compareAll ? COMPARE_ROW_HEIGHT : ROW_HEIGHT
|
||||||
const innerWidth = CHART_WIDTH - MARGIN.left - MARGIN.right
|
const innerWidth = CHART_WIDTH - MARGIN.left - MARGIN.right
|
||||||
const bandHeight = rowHeight + RAIL_HEIGHT
|
const fontPx = compareAll ? COMPACT_LABEL_FONT_PX : LABEL_FONT_PX
|
||||||
const height = MARGIN.top + rows.length * bandHeight + MARGIN.bottom
|
const lineHeight = compareAll ? COMPACT_LABEL_LINE_HEIGHT : LABEL_LINE_HEIGHT
|
||||||
|
|
||||||
const x = scaleLinear().domain([0, domain.niceMax]).range([MARGIN.left, MARGIN.left + innerWidth])
|
const x = scaleLinear().domain([0, domain.niceMax]).range([MARGIN.left, MARGIN.left + innerWidth])
|
||||||
|
|
||||||
|
// Lay the labels out before sizing the SVG: how many lanes they need decides
|
||||||
|
// how much room each row has to reserve above its curves.
|
||||||
|
let cursor = MARGIN.top
|
||||||
|
const placedRows = rows.map((row) => {
|
||||||
|
const drawable = row.entries.filter((e) => e.profile)
|
||||||
|
const { labels, lanes } = layoutPeakLabels(
|
||||||
|
drawable.map((e) => ({
|
||||||
|
key: e.group.key,
|
||||||
|
x: peakX(e.profile, x, domain.niceMax),
|
||||||
|
text: e.group.shortLabel,
|
||||||
|
color: raceColor(e.group.race),
|
||||||
|
})),
|
||||||
|
{ min: MARGIN.left, max: MARGIN.left + innerWidth, fontPx, gap: LABEL_GAP },
|
||||||
|
)
|
||||||
|
const specLabel = compareAll ? SPEC_LABEL_HEIGHT : 0
|
||||||
|
const band = specLabel + (lanes > 0 ? lanes * lineHeight + 3 : 0)
|
||||||
|
const top = cursor
|
||||||
|
cursor += band + rowHeight + RAIL_HEIGHT
|
||||||
|
return { row, drawable, labels, band, specLabel, top, lineHeight, fontPx }
|
||||||
|
})
|
||||||
|
|
||||||
|
const plotBottom = cursor
|
||||||
|
const height = plotBottom + MARGIN.bottom
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure style={{ margin: '0 0 var(--space-2)' }}>
|
<figure style={{ margin: '0 0 var(--space-2)' }}>
|
||||||
{label && <figcaption style={panelLabelStyle}>{label}</figcaption>}
|
{label && <figcaption style={panelLabelStyle}>{label}</figcaption>}
|
||||||
@@ -242,19 +290,18 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
|
|||||||
x1={x(t)}
|
x1={x(t)}
|
||||||
y1={MARGIN.top}
|
y1={MARGIN.top}
|
||||||
x2={x(t)}
|
x2={x(t)}
|
||||||
y2={MARGIN.top + rows.length * bandHeight}
|
y2={plotBottom}
|
||||||
stroke="var(--cv-rule)"
|
stroke="var(--cv-rule)"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{rows.map((row, i) => (
|
{placedRows.map((placed) => (
|
||||||
<ModelRow
|
<ModelRow
|
||||||
key={row.model}
|
key={placed.row.model}
|
||||||
row={row}
|
placed={placed}
|
||||||
compareAll={compareAll}
|
compareAll={compareAll}
|
||||||
x={x}
|
x={x}
|
||||||
top={MARGIN.top + i * bandHeight}
|
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
acByKey={acByKey}
|
acByKey={acByKey}
|
||||||
niceMax={domain.niceMax}
|
niceMax={domain.niceMax}
|
||||||
@@ -265,7 +312,7 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
|
|||||||
<text
|
<text
|
||||||
key={t}
|
key={t}
|
||||||
x={x(t)}
|
x={x(t)}
|
||||||
y={MARGIN.top + rows.length * bandHeight + 15}
|
y={plotBottom + 15}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="0.62rem"
|
fontSize="0.62rem"
|
||||||
fill="var(--cv-ink-3)"
|
fill="var(--cv-ink-3)"
|
||||||
@@ -289,10 +336,10 @@ function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, comp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
|
function ModelRow({ placed, compareAll, x, rowHeight, acByKey, niceMax }) {
|
||||||
const baselineY = top + rowHeight
|
const { row, drawable, labels, band, specLabel, top, lineHeight, fontPx } = placed
|
||||||
|
const baselineY = top + band + rowHeight
|
||||||
const peakHeight = rowHeight * 0.86
|
const peakHeight = rowHeight * 0.86
|
||||||
const drawable = row.entries.filter((e) => e.profile)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
@@ -302,7 +349,28 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
|
|||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{drawable.map((entry, i) => (
|
{/* Direct labels, packed into lanes so overlapping densities stay legible.
|
||||||
|
Colour is what ties each one to its curve — the same hue the table's
|
||||||
|
swatch uses — so no leader lines are needed. */}
|
||||||
|
{labels.map((label) => (
|
||||||
|
<text
|
||||||
|
key={label.key}
|
||||||
|
x={label.x}
|
||||||
|
y={top + specLabel + label.lane * lineHeight + fontPx}
|
||||||
|
textAnchor={label.anchor}
|
||||||
|
fontSize={`${fontPx}px`}
|
||||||
|
fontWeight={700}
|
||||||
|
fill={label.color}
|
||||||
|
stroke="var(--cv-paper)"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
paintOrder="stroke"
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{drawable.map((entry) => (
|
||||||
<GroupArea
|
<GroupArea
|
||||||
key={entry.group.key}
|
key={entry.group.key}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
@@ -314,8 +382,6 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
|
|||||||
// what the reader is comparing here is location and spread, not peak
|
// what the reader is comparing here is location and spread, not peak
|
||||||
// height.
|
// height.
|
||||||
peakHeight={peakHeight}
|
peakHeight={peakHeight}
|
||||||
labelIndex={i}
|
|
||||||
compact={compareAll}
|
|
||||||
niceMax={niceMax}
|
niceMax={niceMax}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -347,7 +413,7 @@ function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceMax }) {
|
function GroupArea({ entry, x, baselineY, peakHeight, niceMax }) {
|
||||||
const { profile, group } = entry
|
const { profile, group } = entry
|
||||||
const color = raceColor(group.race)
|
const color = raceColor(group.race)
|
||||||
const y = scaleLinear().domain([0, profile.maxY || 1]).range([baselineY, baselineY - peakHeight])
|
const y = scaleLinear().domain([0, profile.maxY || 1]).range([baselineY, baselineY - peakHeight])
|
||||||
@@ -366,10 +432,6 @@ function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceM
|
|||||||
// still visually rhyming with the filled areas beside it.
|
// still visually rhyming with the filled areas beside it.
|
||||||
.curve(profile.kind === 'mass' ? curveStep : curveMonotoneX)
|
.curve(profile.kind === 'mass' ? curveStep : curveMonotoneX)
|
||||||
|
|
||||||
const peak = profile.points.reduce((best, p) => (p.y > best.y ? p : best), profile.points[0])
|
|
||||||
const peakX = x(Math.min(peak.x, niceMax))
|
|
||||||
const labelY = Math.max(baselineY - peakHeight - 2, y(peak.y) - 4 - (labelIndex % 2) * 11)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<path d={areaGen(points)} fill={color} opacity={FILL_OPACITY} />
|
<path d={areaGen(points)} fill={color} opacity={FILL_OPACITY} />
|
||||||
@@ -380,20 +442,6 @@ function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceM
|
|||||||
strokeWidth={STROKE_WIDTH}
|
strokeWidth={STROKE_WIDTH}
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
{/* Direct-labelled at the peak so there is no legend to hunt through. */}
|
|
||||||
<text
|
|
||||||
x={peakX}
|
|
||||||
y={labelY}
|
|
||||||
textAnchor={peakX > x(niceMax) * 0.8 ? 'end' : 'middle'}
|
|
||||||
fontSize={compact ? '0.58rem' : '0.65rem'}
|
|
||||||
fontWeight={700}
|
|
||||||
fill={color}
|
|
||||||
stroke="var(--cv-paper)"
|
|
||||||
strokeWidth={3}
|
|
||||||
paintOrder="stroke"
|
|
||||||
>
|
|
||||||
{group.shortLabel}
|
|
||||||
</text>
|
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,22 @@ export default function ChartPanel({ district, state }) {
|
|||||||
without referral-rate covariates. All rates are per 1,000 students. Modelled distributions
|
without referral-rate covariates. All rates are per 1,000 students. Modelled distributions
|
||||||
are drawn from the published posterior predictive draws, fetched in the browser.
|
are drawn from the published posterior predictive draws, fetched in the browser.
|
||||||
</p>
|
</p>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-2)' }}>
|
||||||
|
<strong>Student groups not shown.</strong> The CRDC also reports arrests for Asian,
|
||||||
|
Hawaiian/Pacific Islander, and multiracial students. The underlying research does not
|
||||||
|
model these groups: it focuses on the four race/ethnicity groups where prior work has
|
||||||
|
documented the starkest disparities — Black and American Indian/Alaska Native students
|
||||||
|
relative to White students — with Hispanic students included for their large and growing
|
||||||
|
share of U.S. enrollment. Fitting every reported group would also have meant fitting 70
|
||||||
|
stratified models rather than 40. Their absence here reflects that scope, not a finding
|
||||||
|
about those students, and no estimate for them should be inferred from this page. See the{' '}
|
||||||
|
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">
|
||||||
|
white paper
|
||||||
|
</a>{' '}
|
||||||
|
for the full rationale. Arrests that cannot be disaggregated by both race and sex —
|
||||||
|
including Section 504 students — are excluded upstream, as are schools not enrolling
|
||||||
|
grade 7 or above.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Collision-free placement for direct labels on a shared axis.
|
||||||
|
*
|
||||||
|
* The density panel labels each curve at its own peak rather than shipping a
|
||||||
|
* legend, which only works if the labels don't collide — and in this app they
|
||||||
|
* collide constantly, because the interesting districts are exactly the ones
|
||||||
|
* where several groups have similar rates. Worse, each density is normalized to
|
||||||
|
* its own peak height, so every curve peaks at the *same* y and a naive
|
||||||
|
* placement puts every label on one line: three overlapping groups rendered as
|
||||||
|
* "HiWhite:F: Black F".
|
||||||
|
*
|
||||||
|
* So labels are packed into horizontal lanes: first-fit by x, dropping to a new
|
||||||
|
* lane only when the previous one is occupied at that position. Groups that are
|
||||||
|
* far apart still share a lane, which keeps the common case compact.
|
||||||
|
*
|
||||||
|
* Pure geometry — no DOM, no measurement. SVG text can't be measured before
|
||||||
|
* render, so widths are estimated from character count; the estimate is
|
||||||
|
* deliberately generous so labels err toward extra separation rather than
|
||||||
|
* overlap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mean advance width of a glyph as a fraction of font size, for the app's sans
|
||||||
|
// stack at the weights these labels use. Slightly over the true average so the
|
||||||
|
// packing errs toward separation.
|
||||||
|
const CHAR_WIDTH_RATIO = 0.62
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} fontPx
|
||||||
|
* @returns {number} approximate rendered width in px
|
||||||
|
*/
|
||||||
|
export function estimateTextWidth(text, fontPx) {
|
||||||
|
return (text || '').length * fontPx * CHAR_WIDTH_RATIO
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{key: string, x: number, text: string}>} items - one per label,
|
||||||
|
* `x` being the point it wants to sit above (a curve's peak).
|
||||||
|
* @param {{min: number, max: number, fontPx: number, gap?: number}} options -
|
||||||
|
* `min`/`max` are the plot's horizontal bounds; labels are kept inside them.
|
||||||
|
* @returns {{labels: Array<{key: string, text: string, x: number,
|
||||||
|
* anchor: 'start'|'middle'|'end', width: number, left: number, right: number,
|
||||||
|
* lane: number}>, lanes: number}}
|
||||||
|
* `labels` is in input order; `lanes` is how many rows the caller must
|
||||||
|
* reserve above the plot.
|
||||||
|
*/
|
||||||
|
export function layoutPeakLabels(items, { min, max, fontPx, gap = 6 } = {}) {
|
||||||
|
if (!items?.length) return { labels: [], lanes: 0 }
|
||||||
|
|
||||||
|
const placed = items.map((item) => {
|
||||||
|
const width = estimateTextWidth(item.text, fontPx)
|
||||||
|
const half = width / 2
|
||||||
|
|
||||||
|
// Anchor outward near the edges so a centred label can't overflow the plot.
|
||||||
|
let anchor = 'middle'
|
||||||
|
if (item.x - half < min) anchor = 'start'
|
||||||
|
else if (item.x + half > max) anchor = 'end'
|
||||||
|
|
||||||
|
// Clamp the anchor point itself, so a peak clipped to the axis edge still
|
||||||
|
// yields a label fully inside the frame.
|
||||||
|
let x = item.x
|
||||||
|
if (anchor === 'start') x = Math.max(min, Math.min(x, max - width))
|
||||||
|
else if (anchor === 'end') x = Math.min(max, Math.max(x, min + width))
|
||||||
|
else x = Math.min(Math.max(x, min + half), max - half)
|
||||||
|
|
||||||
|
const left = anchor === 'start' ? x : anchor === 'end' ? x - width : x - half
|
||||||
|
return { ...item, width, anchor, x, left, right: left + width, lane: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// First-fit by left edge. Sorting only decides lane order; the returned array
|
||||||
|
// keeps the caller's original order.
|
||||||
|
const laneRightEdge = []
|
||||||
|
for (const label of [...placed].sort((a, b) => a.left - b.left)) {
|
||||||
|
let lane = 0
|
||||||
|
while (lane < laneRightEdge.length && laneRightEdge[lane] + gap > label.left) lane++
|
||||||
|
laneRightEdge[lane] = label.right
|
||||||
|
label.lane = lane
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels: placed, lanes: laneRightEdge.length }
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { estimateTextWidth, layoutPeakLabels } from './labelLayout.js'
|
||||||
|
|
||||||
|
const BOUNDS = { min: 0, max: 400, fontPx: 10 }
|
||||||
|
|
||||||
|
test('estimateTextWidth: grows with text length and font size', () => {
|
||||||
|
assert.ok(estimateTextWidth('AB', 10) > estimateTextWidth('A', 10))
|
||||||
|
assert.ok(estimateTextWidth('ABC', 20) > estimateTextWidth('ABC', 10))
|
||||||
|
assert.ok(estimateTextWidth('', 10) >= 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: well-separated labels all sit on lane 0', () => {
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[{ key: 'a', x: 20, text: 'A' }, { key: 'b', x: 200, text: 'B' }, { key: 'c', x: 380, text: 'C' }],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
assert.deepEqual(out.labels.map((l) => l.lane), [0, 0, 0])
|
||||||
|
assert.equal(out.lanes, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: overlapping labels are pushed to separate lanes', () => {
|
||||||
|
// This is the failing case from the live site: three densities peaking at
|
||||||
|
// nearly the same rate rendered as "HiWhite:F: Black F".
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[
|
||||||
|
{ key: 'hi', x: 100, text: 'Hispanic F' },
|
||||||
|
{ key: 'wh', x: 104, text: 'White F' },
|
||||||
|
{ key: 'bl', x: 108, text: 'Black F' },
|
||||||
|
],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
const lanes = out.labels.map((l) => l.lane).sort()
|
||||||
|
assert.deepEqual(lanes, [0, 1, 2])
|
||||||
|
assert.equal(out.lanes, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: identical positions never share a lane', () => {
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[
|
||||||
|
{ key: 'a', x: 200, text: 'Hispanic F' },
|
||||||
|
{ key: 'b', x: 200, text: 'Hispanic M' },
|
||||||
|
],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
assert.notEqual(out.labels[0].lane, out.labels[1].lane)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: a lane is reused once there is horizontal room', () => {
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[
|
||||||
|
{ key: 'a', x: 20, text: 'A' },
|
||||||
|
{ key: 'b', x: 24, text: 'B' },
|
||||||
|
{ key: 'c', x: 380, text: 'C' },
|
||||||
|
],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
const byKey = Object.fromEntries(out.labels.map((l) => [l.key, l.lane]))
|
||||||
|
assert.equal(byKey.a, 0)
|
||||||
|
assert.equal(byKey.b, 1)
|
||||||
|
// 'c' is far away, so it drops back to the first lane rather than stacking.
|
||||||
|
assert.equal(byKey.c, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: anchors outward at the edges so text stays in frame', () => {
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[{ key: 'l', x: 0, text: 'Hispanic F' }, { key: 'r', x: 400, text: 'Hispanic F' }],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
const byKey = Object.fromEntries(out.labels.map((l) => [l.key, l]))
|
||||||
|
assert.equal(byKey.l.anchor, 'start')
|
||||||
|
assert.equal(byKey.r.anchor, 'end')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: a mid-plot label stays centred on its peak', () => {
|
||||||
|
const out = layoutPeakLabels([{ key: 'm', x: 200, text: 'Black F' }], BOUNDS)
|
||||||
|
assert.equal(out.labels[0].anchor, 'middle')
|
||||||
|
assert.equal(out.labels[0].x, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: no label extends outside the plot bounds', () => {
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[
|
||||||
|
{ key: 'l', x: -50, text: 'American Indian / Alaska Native' },
|
||||||
|
{ key: 'r', x: 900, text: 'American Indian / Alaska Native' },
|
||||||
|
],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
for (const l of out.labels) {
|
||||||
|
assert.ok(l.left >= BOUNDS.min - 0.01, `${l.key} left ${l.left} < ${BOUNDS.min}`)
|
||||||
|
assert.ok(l.right <= BOUNDS.max + 0.01, `${l.key} right ${l.right} > ${BOUNDS.max}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: preserves input order in the output', () => {
|
||||||
|
// Lane assignment sorts internally; callers still key off their own order.
|
||||||
|
const out = layoutPeakLabels(
|
||||||
|
[{ key: 'z', x: 300, text: 'Z' }, { key: 'a', x: 10, text: 'A' }],
|
||||||
|
BOUNDS,
|
||||||
|
)
|
||||||
|
assert.deepEqual(out.labels.map((l) => l.key), ['z', 'a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: empty input yields zero lanes', () => {
|
||||||
|
const out = layoutPeakLabels([], BOUNDS)
|
||||||
|
assert.deepEqual(out.labels, [])
|
||||||
|
assert.equal(out.lanes, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('layoutPeakLabels: wider gap forces more lanes', () => {
|
||||||
|
const items = [{ key: 'a', x: 100, text: 'A' }, { key: 'b', x: 130, text: 'B' }]
|
||||||
|
const tight = layoutPeakLabels(items, { ...BOUNDS, gap: 0 })
|
||||||
|
const loose = layoutPeakLabels(items, { ...BOUNDS, gap: 40 })
|
||||||
|
assert.ok(loose.lanes > tight.lanes)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user