Clean up the hero chart: remove watermark, fix label collisions, round axis ticks
Deploy to git-pages / deploy (push) Successful in 12s
Deploy to git-pages / deploy (push) Successful in 12s
The district-name watermark inside the arrests-over-time chart was redundant with the page's own heading and was the direct cause of a label collision (a high point's rate label rendered on top of it). Removes it. Two more collisions, both edge cases the screenshot happened to hit: the first wave's label sat close enough to the plot's top that it could overlap the topmost gridline text, and a point sitting flush on the plot's left edge had its center-anchored label bleed into the y-axis tick labels. Fixes both with more vertical clearance and edge-aware text anchoring (first point anchors right, last point anchors left, matching the same fix already applied to chart 4's wave ticks before that chart was cut). Adds a shared niceTicks() utility (Heckbert's nice-numbers algorithm) so axis ticks read as round numbers (0/100/200) instead of arbitrary fractions of the data max (0/113/226/339) — applied to all three charts for consistency. Also fixes a legend/mark mismatch in the arrests-over-time chart (the modeled series legend showed a diamond; the actual mark is a dot) by adding a proper 'dot' shape to ChartLegend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import { OBSERVED_MARK_COLOR, MODELED_AGGREGATE_COLOR } from '../utils/colors.js'
|
||||
import { niceTicks } from '../utils/niceTicks.js'
|
||||
|
||||
/**
|
||||
* Chart 1: Observed arrests by CRDC wave (line), overlaid with the
|
||||
@@ -8,22 +9,26 @@ 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 DODGE = 14 // px offset so the modeled point-range doesn't sit on top of the observed point
|
||||
const DODGE = 18 // px offset so the modeled point-range doesn't sit on top of the observed point
|
||||
const LABEL_CLEARANCE = 32 // px reserved above the taller of (observed, modeled upper) for its rate label
|
||||
|
||||
export default function ArrestsOverTime({ data, districtName }) {
|
||||
export default function ArrestsOverTime({ data }) {
|
||||
const maxArrests = Math.max(
|
||||
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
|
||||
1
|
||||
)
|
||||
const { ticks, niceMax } = niceTicks(maxArrests)
|
||||
|
||||
const width = 360
|
||||
const height = 280
|
||||
const margin = { top: 50, right: 90, bottom: 60, left: 65 }
|
||||
const margin = { top: 40, right: 40, bottom: 60, left: 65 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const innerHeight = height - margin.top - margin.bottom
|
||||
|
||||
const xScale = (i) => (i / Math.max(data.length - 1, 1)) * innerWidth
|
||||
const yScale = (val) => innerHeight - (val / maxArrests) * innerHeight
|
||||
// Reserves LABEL_CLEARANCE px at the top of the plot so a point's own rate
|
||||
// label never has to compete for space with the modeled whisker above it.
|
||||
const yScale = (val) => LABEL_CLEARANCE + (innerHeight - LABEL_CLEARANCE) * (1 - val / niceMax)
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
@@ -35,16 +40,14 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
<rect x={margin.left} y={margin.top} width={innerWidth} height={innerHeight}
|
||||
fill="var(--cv-paper-2)" rx={4} />
|
||||
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxArrests
|
||||
if (val === 0 && frac > 0) return null
|
||||
{ticks.map((val) => {
|
||||
const y = margin.top + yScale(val)
|
||||
return (
|
||||
<g key={`y-${frac}`}>
|
||||
<g key={`y-${val}`}>
|
||||
<line x1={margin.left} y1={y} x2={margin.left + innerWidth} y2={y}
|
||||
stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={margin.left - 8} y={y + 4} textAnchor="end"
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)">{Math.round(val).toLocaleString()}</text>
|
||||
fontSize="0.7rem" fill="var(--cv-ink-3)">{val.toLocaleString()}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
@@ -88,30 +91,37 @@ export default function ArrestsOverTime({ data, districtName }) {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Observed points + rate-per-1k labels */}
|
||||
{/* Observed points + rate-per-1k labels, cleared above whichever of
|
||||
the observed point or the modeled whisker reaches higher */}
|
||||
{data.map((d, i) => {
|
||||
const cx = margin.left + xScale(i)
|
||||
const cy = margin.top + yScale(d.arrests)
|
||||
const modeledUpperY = d.modeledUpper != null ? margin.top + yScale(d.modeledUpper) : cy
|
||||
const topY = Math.min(cy, modeledUpperY)
|
||||
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.0'
|
||||
// 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 + 6 : isLast ? cx - 6 : cx
|
||||
|
||||
return (
|
||||
<g key={d.year}>
|
||||
<circle cx={cx} cy={cy} r={5} fill={OBSERVED_MARK_COLOR} stroke="#fff" strokeWidth={1.5} />
|
||||
<text x={cx} y={cy - 14} textAnchor="middle" fontSize="0.7rem" fontWeight={600}
|
||||
<text x={labelX} y={topY - 15} textAnchor={anchor} fontSize="0.7rem" fontWeight={600}
|
||||
fill="var(--cv-ink)">{ratePerK}</text>
|
||||
<text x={cx} y={cy - 5} textAnchor="middle" fontSize="0.6rem"
|
||||
<text x={labelX} y={topY - 6} textAnchor={anchor} fontSize="0.6rem"
|
||||
fill="var(--cv-ink-3)">per 1k</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
<text x={margin.left + innerWidth - 4} y={margin.top + 12} textAnchor="end"
|
||||
fontSize="0.6rem" fill="var(--cv-ink-4)">{districtName}</text>
|
||||
</svg>
|
||||
|
||||
<ChartLegend items={[
|
||||
{ shape: 'line', color: OBSERVED_MARK_COLOR, label: 'Observed' },
|
||||
{ shape: 'diamond', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
||||
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
|
||||
]} />
|
||||
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import ApproxNote from '../components/ApproxNote.jsx'
|
||||
import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js'
|
||||
import { fitSkewedInterval } from '../utils/distributionApprox.js'
|
||||
import { niceTicks } from '../utils/niceTicks.js'
|
||||
|
||||
/**
|
||||
* Arrest rate by student group, most recent year, disaggregated into two
|
||||
@@ -32,6 +33,7 @@ export default function RateByGroupBar({ data }) {
|
||||
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
|
||||
0.5
|
||||
)
|
||||
const { ticks, niceMax } = niceTicks(maxRate, 4)
|
||||
|
||||
return (
|
||||
<div className="cv-card" style={{ padding: 'var(--space-2)' }}>
|
||||
@@ -42,7 +44,7 @@ export default function RateByGroupBar({ data }) {
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
||||
{SEX_PANELS.map(({ sex, label }) => (
|
||||
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} maxRate={maxRate} />
|
||||
<SexPanel key={sex} label={label} rows={data.filter((d) => d.sex === sex)} ticks={ticks} niceMax={niceMax} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -61,14 +63,14 @@ export default function RateByGroupBar({ data }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SexPanel({ label, rows, maxRate }) {
|
||||
function SexPanel({ label, rows, ticks, niceMax }) {
|
||||
const width = 300
|
||||
const margin = { top: 30, right: 16, bottom: 34, left: 66 }
|
||||
const innerWidth = width - margin.left - margin.right
|
||||
const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean)
|
||||
const bodyHeight = byRace.length * ROW_HEIGHT
|
||||
const height = margin.top + bodyHeight + margin.bottom
|
||||
const xScale = (val) => margin.left + (val / maxRate) * innerWidth
|
||||
const xScale = (val) => margin.left + (val / niceMax) * innerWidth
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--cv-rule)', borderRadius: 'var(--radius-md)', padding: 'var(--space-1)' }}>
|
||||
@@ -79,14 +81,13 @@ function SexPanel({ label, rows, maxRate }) {
|
||||
|
||||
<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
|
||||
{ticks.map((val) => {
|
||||
const x = xScale(val)
|
||||
return (
|
||||
<g key={frac}>
|
||||
<g key={val}>
|
||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + bodyHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={x} y={margin.top + bodyHeight + 14} textAnchor="middle" fontSize="0.6rem" fill="var(--cv-ink-3)">
|
||||
{val.toFixed(1)}
|
||||
{val}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
|
||||
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
|
||||
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
|
||||
import { niceTicks } from '../utils/niceTicks.js'
|
||||
import ChartLegend from '../components/ChartLegend.jsx'
|
||||
import ApproxNote from '../components/ApproxNote.jsx'
|
||||
|
||||
@@ -80,18 +81,19 @@ function RidgeColumns({ rows }) {
|
||||
const allObserved = rows
|
||||
.filter((r) => (r.stu_enroll || 0) > 0)
|
||||
.map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000)
|
||||
const maxRate = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
|
||||
const rawMax = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30)
|
||||
const { ticks, niceMax } = niceTicks(rawMax, 5)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
||||
{SEX_COLUMNS.map(({ sex, label }) => (
|
||||
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} maxRate={maxRate} />
|
||||
<SexRidgeColumn key={sex} label={label} rows={rows.filter((r) => r.sex === sex)} ticks={ticks} maxRate={niceMax} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SexRidgeColumn({ label, rows, maxRate }) {
|
||||
function SexRidgeColumn({ label, rows, ticks, maxRate }) {
|
||||
const groups = RACE_ORDER.map((race) => rows.find((r) => r.race === race)).filter(Boolean).map(buildGroupRow)
|
||||
|
||||
const width = 300
|
||||
@@ -119,14 +121,13 @@ function SexRidgeColumn({ label, rows, maxRate }) {
|
||||
</text>
|
||||
) : (
|
||||
<>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
||||
const val = frac * maxRate
|
||||
{ticks.map((val) => {
|
||||
const x = xScale(val)
|
||||
return (
|
||||
<g key={frac}>
|
||||
<g key={val}>
|
||||
<line x1={x} y1={margin.top} x2={x} y2={margin.top + groups.length * rowHeight} stroke="var(--cv-rule)" strokeWidth={1} />
|
||||
<text x={x} y={margin.top + groups.length * rowHeight + 14} textAnchor="middle" fontSize="0.58rem" fill="var(--cv-ink-3)">
|
||||
{val.toFixed(0)}
|
||||
{val}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Both of the app's stray-`}` CSS bugs lived inside hand-written SVG legend
|
||||
* `fill="..."` strings; keeping legends out of SVG removes that bug class.
|
||||
*
|
||||
* items: [{ color, label, shape: 'swatch' | 'diamond' | 'line' }]
|
||||
* items: [{ color, label, shape: 'swatch' | 'diamond' | 'line' | 'dot' }]
|
||||
*/
|
||||
export default function ChartLegend({ items }) {
|
||||
return (
|
||||
@@ -36,5 +36,8 @@ function LegendMark({ shape, color }) {
|
||||
if (shape === 'line') {
|
||||
return <span style={{ display: 'inline-block', width: '16px', height: '3px', background: color, borderRadius: '2px', flexShrink: 0 }} />
|
||||
}
|
||||
if (shape === 'dot') {
|
||||
return <span style={{ display: 'inline-block', width: '10px', height: '10px', background: color, borderRadius: '50%', flexShrink: 0 }} />
|
||||
}
|
||||
return <span style={{ display: 'inline-block', width: '11px', height: '11px', background: color, borderRadius: '3px', flexShrink: 0 }} />
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function ChartPanel({ district, state }) {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto'
|
||||
}}>
|
||||
<ArrestsOverTime data={timeSeriesData} districtName={district.lea_name} />
|
||||
<ArrestsOverTime data={timeSeriesData} />
|
||||
|
||||
<RateByGroupBar data={rateByGroup} />
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* "Nice numbers" axis tick generator (Heckbert 1990) — produces clean,
|
||||
* human-friendly tick values (0 / 50 / 100 / 150 rather than 0 / 37 / 74 /
|
||||
* 111, an artifact of slicing frac*max into equal fractions) for any linear
|
||||
* axis, from just the data's max value.
|
||||
*/
|
||||
function niceNumber(range, round) {
|
||||
const exponent = Math.floor(Math.log10(range))
|
||||
const fraction = range / 10 ** exponent
|
||||
let niceFraction
|
||||
if (round) {
|
||||
if (fraction < 1.5) niceFraction = 1
|
||||
else if (fraction < 3) niceFraction = 2
|
||||
else if (fraction < 7) niceFraction = 5
|
||||
else niceFraction = 10
|
||||
} else {
|
||||
if (fraction <= 1) niceFraction = 1
|
||||
else if (fraction <= 2) niceFraction = 2
|
||||
else if (fraction <= 5) niceFraction = 5
|
||||
else niceFraction = 10
|
||||
}
|
||||
return niceFraction * 10 ** exponent
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} maxValue - the largest value the axis must cover
|
||||
* @param {number} tickCount - approximate desired number of ticks
|
||||
* @returns {{ ticks: number[], niceMax: number }}
|
||||
*/
|
||||
export function niceTicks(maxValue, tickCount = 5) {
|
||||
if (!(maxValue > 0)) return { ticks: [0], niceMax: 1 }
|
||||
const niceRange = niceNumber(maxValue, false)
|
||||
const niceStep = niceNumber(niceRange / (tickCount - 1), true)
|
||||
const niceMax = Math.ceil(maxValue / niceStep) * niceStep
|
||||
const ticks = []
|
||||
for (let v = 0; v <= niceMax + niceStep / 2; v += niceStep) {
|
||||
ticks.push(Math.round(v * 1e6) / 1e6)
|
||||
}
|
||||
return { ticks, niceMax }
|
||||
}
|
||||
Reference in New Issue
Block a user