Show a family's behaviours as one chart, not just a table - #491
Conversation
Issue #458, stage 1 of the proposal on that issue: a chart per metric family, with the cross-family chart builder to follow as a second entry point into the same component. "The metrics in detail" gave each family a wide per-pilot table and some prose. Charts appeared only where the engine emitted an extraSeries, and only 2 of the 26 metrics do — so four of the six families had no chart at all, and nothing answered the question a family IS: was this pilot good across the whole of climbing, or only one part of it? MetricLanes draws one lane per behaviour on ONE shared 0-100 percentile axis, one dot per pilot, and joins one pilot's dots down the lanes. The table's columns are in different units and cannot be compared with each other; the heatmap compares everything at once but as luminance, with no numbers, and since #485 it no longer groups by family at all — so this is now the only per-family reading on the page. Exactly one connector line ever exists, which is why this is lanes and not parallel coordinates: forty polylines over six axes is spaghetti, and the usual fix is to stop drawing thirty-five of them. It breaks across a lane where the pilot has no value rather than inventing one. Two decisions, both from driving it: - The lanes default to the winner's profile. A percentile axis spreads the field near-evenly by construction, so with nobody hovered the chart was rows of dots and little else — which is all print and a reader who never hovers would ever get. Same "strongest finding on first paint" the separation ranking and leg waterfall already do, and print gets a line naming whose profile is drawn, since the hover readout can't exist on paper. - Lanes run strongest-separator first, reusing the orientedRho() that #485 added, and outcome-derived metrics get no lane — their lane would run cleanly best-to-worst and read as a finding about flying. Both match the reordered heatmap. The table below keeps registry order, so the caption says what orders the lanes. The caption states what a reader would otherwise infer wrongly: that a percentile axis says nothing about spread (hence the value labels at each lane's ends, and no quartile band — on this axis it is always 25-75), and that right is the end a behaviour is EXPECTED to be better at, never a claim it paid on this task. Neutral metrics are not oriented and wear †, as in the heatmap. Percentiles are midrank, matching every other percentile on the page — the heatmap cell, the ⓘ popover strip, the cluster medians. The engine's private percentileColumns() uses a different formula and stays private. All the filtering, ordering and prose is in a pure metric-lanes.ts with 24 tests, following PercentileHeatmap.test.ts. The sort's id tie-break is a plain codepoint compare, not localeCompare: these pages are SSR'd and collation differing between ICUs is a hydration mismatch. Accessibility follows the heatmap's contract: one role="img" figure whose name reads every lane in words and points at the table below as the data equivalent. The dots are deliberately not focusable — six lanes of forty pilots is 240 tab stops for values already in a keyboard-navigable table. bun run test, typecheck:all, the frontend vitest suite (443) and test:e2e:ssr (30 passed) all pass. Driven against the seeded Corryong Cup 2026 field in a real browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1Zrf2tMEP2QHK6jWboAQq
Merges master (2cb994c) and audits MetricLanes across the whole glidecomp-archive: 184 tasks, 1,104 families, 3,239 lanes. New web/scripts/audit-metric-lanes.ts, following audit-score-charts.ts: it scores every task in a comp library with the real engine, builds the real field-analysis report, runs buildLanes over each family the way MetricFamilySection does, and checks the invariants the drawing code relies on — dots pair by trackFile, every percentile sits strictly inside (0,100), the value printed at a lane's end is the value of the dot at that end (the one most likely to be silently wrong, since a 'lower'-is-better lane is flipped), the order is deterministic under reversed input, and no outcome metric gets a lane. Zero invariant violations across the archive. The three fixes below all come from things the audit COUNTED rather than things it failed on — each renders as a blank or an average-looking dot, which is why no test was going to catch them. 1. A lane measuring ONE pilot is dropped (136 of 3,401 lanes). Midrank puts a lone value at exactly 50, so the lane stated "this pilot was dead average" when it meant "only one pilot could be measured". There is no field for a percentile to sit within. The value is still in the per-pilot table, which is where a single reading belongs. This also halved the all-identical lanes, 283 -> 127. 2. The default profile falls past a leader with nothing to draw (5 of 861 charts). It was rank 1 unconditionally; a winner who never shared a thermal has no gaggle metrics at all, so those charts drew no connector — the rows-of-dots state the default exists to prevent. Now the best-ranked pilot appearing in at least two lanes, because a single dot is not a profile. Zero such charts remain. 3. An all-identical lane says so. 127 lanes stack every dot on P50 under a bare figure, which reads as a broken chart; it now reads "every pilot 0.4 m/s". Also adopts two conventions master landed while this was open: the shared XAxisTitle/YAxisTitle from charts/AxisTitle.tsx (#501 — every chart names its axes in the plot, because a caption is read once and an axis continuously), and nothing needed for #520's decision.altitude_floor going 'higher' -> 'neutral', since the orientation is data-driven. bun run test:all (1279 engine/script, 584 frontend, 508 comp, 92 auth), typecheck:all, and test:e2e:ssr (33 passed). Re-driven in a browser: axis titles clear the gutter, connector and readout unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R1Zrf2tMEP2QHK6jWboAQq
|
Merged up to master ( New
Zero invariant violations across the archive. The three fixes below all came from things the audit counted, not things it failed on — each renders as a blank or an average-looking dot, which is exactly why no unit test on synthetic metrics was going to catch them. 1. A lane measuring one pilot is dropped — 136 of 3,401 lanesMidrank puts a lone value at exactly 50, so the lane stated "this pilot was dead average" when it meant "only one pilot could be measured". There is no field for a percentile to sit within. The coverage count disclosed the Side effect: it halved the all-identical lanes, 283 → 127. 2. The default profile falls past a leader with nothing to draw — 5 of 861 chartsIt was rank 1 unconditionally. A winner who never shared a thermal has no gaggle metrics at all, so those charts drew no connector — precisely the rows-of-dots state the default profile exists to prevent. It is now the best-ranked pilot appearing in at least two lanes, since a single dot is not a profile and the connector needs two points to exist. Zero such charts remain; 5 charts now legitimately draw someone other than the winner. 3. An all-identical lane says so127 lanes stack every dot on P50 under a bare figure, which reads as a broken chart rather than as the finding it is. The label now reads "every pilot 0.4 m/s". The metrics that produce them are unsurprising — Also adopted from master
Verification
Run it yourself with: (The One thing I did not change, for the record: 891 lanes still measure fewer than 8 pilots — the threshold below which the engine refuses to claim a correlation at all. At n = 2 or 3 a percentile is coarse but not false (it still says who was ahead), and each lane prints its own coverage count. Dropping them would have removed 27% of all lanes and gutted whole charts, so the count discloses it instead. Generated by Claude Code |
|
Preview Deployment |
Stage 1 of #458 — the per-family chart. The cross-family chart builder follows as a second entry point into the same component; see the proposal.
Preview: https://claude-glidecomp-issue-458-v.glidecomp.pages.dev
Merged up to master (
ca777d2) on 29 Jul — clean, no conflicts, and everything re-verified against it. See "Testing" below.The problem
"The metrics in detail" gave each family a wide per-pilot table and some prose. Charts appeared only where the engine emitted an
extraSeries, and only 2 of the 26 metrics do — so four of the six families had no chart at all, and nothing answered the question a family is: was this pilot good across the whole of climbing, or only one part of it?The table's columns are in different units (
pct,m/s,s,km/h,ratio) and cannot be compared with each other. The heatmap compares everything at once but as luminance, with no numbers — and since #485 it no longer groups by family at all, so there was no per-family reading left on the page.The chart
MetricLanesdraws one lane per behaviour on one shared 0–100 percentile axis, one dot per pilot, and joins one pilot's dots down the lanes.Exactly one connector line ever exists, which is why this is lanes and not parallel coordinates: forty polylines over six axes is spaghetti, and the usual fix is to stop drawing thirty-five of them. It breaks across a lane where the pilot has no value rather than inventing one — null here is "not applicable", never a score of zero. Hovering anyone (a dot, a table row, a heatmap row, a cluster chip —
PilotHighlightContextis already page-wide) redraws it for them.Two decisions that came from driving it, not from the plan
SeparationRankingandLegWaterfallalready do. Print gets a line naming whose profile is drawn, since the hover readout cannot exist on paper.orientedRho()that Order the field-at-a-glance columns by what separated the field #485 added, and outcome-derived metrics get no lane — theirs would run cleanly best-to-worst and read as a finding about flying. Both match the reordered heatmap. The per-pilot table below keeps registry order, so the caption says what orders the lanes.What the caption has to say out loud
RankScatter.captionTextalready refuses the prior when a day runs the other way, and the two must not appear to contradict.†, as in the heatmap. A lane's count is the pilots it applied to.Percentiles are midrank, matching every other percentile on the page — the heatmap cell, the ⓘ popover strip, the cluster medians. The engine's private
percentileColumns()uses(rank−1)/(n−1)and stays private; mixing the two would have a lane disagree with the heatmap for the same pilot on the same page.Testing
All numbers below are post-merge, against
ca777d2.metric-lanes.tswith 24 tests, followingPercentileHeatmap.test.ts. Covered: the ordering and its determinism under reversed input, no mutation of the caller's array, the outcome and blank-lane filters,lowerflipping whileneutraldoes not, nulls dropped rather than zeroed,perPilotin a different order fromreport.pilotsstill pairing bytrackFile, zero-variance and single-pilot lanes, and every caption clause appearing only when it applies.localeCompare— these pages are SSR'd and collation differing between workerd's ICU and the browser's is a hydration mismatch.bun run test:all— 1228 engine/script, 540 frontend vitest, 476 competition-api, 92 auth.bun run typecheck:allclean.bun run test:e2e:ssr— 32 passed, including the:taskAnalysisclean-hydration check that the deterministic lane sort guards.Accessibility
Follows the heatmap's contract: one
role="img"figure whose accessible name reads every lane in words (min/median/max, coverage, the†, and any missing top-3 pilot via the existingnotableExcludedRanks) and names the table below as the data equivalent. The dots are deliberately not focusable — six lanes of forty pilots is 240 tab stops for values that are already in a keyboard-navigable table a few pixels down. Hit targets are 18px for WCAG 2.5.8; the connector is--foregroundink rather than a chart hue, since the dots carry identity and the line is an annotation.🤖 Generated with Claude Code
https://claude.ai/code/session_01R1Zrf2tMEP2QHK6jWboAQq