Skip to content

Show a family's behaviours as one chart, not just a table - #491

Draft
pokle wants to merge 4 commits into
masterfrom
claude/glidecomp-issue-458-vzaq6p
Draft

Show a family's behaviours as one chart, not just a table#491
pokle wants to merge 4 commits into
masterfrom
claude/glidecomp-issue-458-vzaq6p

Conversation

@pokle

@pokle pokle commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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

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.

TopOut% †  ····•·····•·········  29/32     ← Jon Durand's climbing profile:
  -16%          61%       94%              topped out high, kept most climbs,
Kept%   †  ······•···········     28/32     found cores fast, left lift early,
Core s  ↓  ········•·······       32/32     circled less roundly than the field
LeaveRate †  ··········•···       32/32
Round   ↓  ····•···············   31/32
Out-climb ↑  ······•···········   32/32
           0    25   50   75  100
              percentile in this field

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 — PilotHighlightContext is already page-wide) redraws it for them.

Two decisions that came from driving it, not from the plan

  • 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 that print, a touch user, and a reader who never hovers would ever get. Defaulting is the same "strongest finding on first paint" that SeparationRanking and LegWaterfall already do. Print gets a line naming whose profile is drawn, since the hover readout cannot exist on paper.
  • Lanes run strongest-separator first, reusing the 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

  • A percentile axis says nothing about spread. Hence the value labels at each lane's ends (the real scale, in the reader's units) and deliberately no quartile band — on this axis it is always 25–75, a mark encoding zero information.
  • Right is the end a behaviour is expected to be better at, never a claim that it paid on this task — that is the ranking's question. RankScatter.captionText already refuses the prior when a day runs the other way, and the two must not appear to contradict.
  • Neutral metrics are not oriented and wear , 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.

  • All filtering, ordering and prose lives in a pure metric-lanes.ts with 24 tests, following PercentileHeatmap.test.ts. Covered: the ordering and its determinism under reversed input, no mutation of the caller's array, the outcome and blank-lane filters, lower flipping while neutral does not, nulls dropped rather than zeroed, perPilot in a different order from report.pilots still pairing by trackFile, zero-variance and single-pilot lanes, and every caption clause appearing only when it applies.
  • The sort's id tie-break is a plain codepoint compare, not 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:all clean.
  • bun run test:e2e:ssr32 passed, including the :taskAnalysis clean-hydration check that the deterministic lane sort guards.
  • Driven in a real browser against the seeded Corryong Cup 2026 field, before and after the merge: 5 lane figures render (the day family correctly gets none — it has one per-pilot metric), the connector zig-zags across all 6 climbing lanes, the readout reads correctly, and the page-wide highlight reaches the table below. The rendering is pixel-identical pre- and post-merge, so Field analysis: put the ranking table and its chart side by side #489's side-by-side ranking layout doesn't disturb it.

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 existing notableExcludedRanks) 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 --foreground ink 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

claude added 4 commits July 27, 2026 16:54
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

pokle commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Merged up to master (2cb994c) and verified the lane charts against 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, never by index
  • every percentile sits strictly inside (0, 100) — the midrank property the caption tells the reader about
  • 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 (an SSR hydration requirement)
  • no outcome metric gets a lane

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 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 coverage count disclosed the n, but position is what a reader reads first, and at n = 1 it is false. The value is still in the per-pilot table, which is where a single reading belongs.

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 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 — 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 so

127 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 — decision.low_saves (38), race.start_delay (37), the gaggle pair (44 between them).

Also adopted from master

Verification

bun run test:all (1279 engine/script, 584 frontend, 508 competition-api, 92 auth), bun run typecheck:all, bun run test:e2e:ssr33 passed. Re-driven in a real browser against the seeded Corryong Cup 2026 field.

Run it yourself with:

bun --tsconfig-override ./tsconfig.json web/scripts/audit-metric-lanes.ts
GLIDECOMP_COMPS_DIR=<archive>/comps bun --tsconfig-override ./tsconfig.json web/scripts/audit-metric-lanes.ts

(The --tsconfig-override is needed because the script imports frontend chart code, whose @/ alias bun otherwise won't resolve from web/scripts/.)

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

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Preview Deployment
https://71aa82e1.glidecomp.pages.dev
Commit: d3294a0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants