Skip to content

Add Heatmap plot with a grid ↔ stacked-bar pivot - #258

Open
IPWright83 wants to merge 21 commits into
masterfrom
claude/heatmap-grouping-animations-nxav0v
Open

IPWright83 wants to merge 21 commits into
masterfrom
claude/heatmap-grouping-animations-nxav0v

Conversation

@IPWright83

@IPWright83 IPWright83 commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Adds a self-contained <Heatmap> chart: a grid of cells - one per rows/columns combination in the data - colored by value along a sequential (or diverging) scale. Like <Treemap>, it accepts chart-level props (data/width/height/etc.) directly - no <XYChart>/<XAxis>/<YAxis> wrapper needed - but internally it composes <XYChart> plus real <XAxis>/<YAxis> (via <HeatmapAxes>), since it always needs both a row and a column axis.
  • The core feature, based on the "Desk Occupancy Heatmap" Observable notebook (https://observablehq.com/d/2eaec302ac0bbfb4): set pivotable to let the user switch between the full grid and two stacked-bar-chart layouts - "rows" collapses the column axis into a linear scale, stacking each row's cells into a single horizontal bar; "columns" does the same collapsing the row axis instead. Every cell is keyed by its row/column pair, so pivoting transitions each one to its new position/size rather than re-creating it.
  • The pivot is switched via the "Pivot" action on the chart's right-click <ContextMenu> (previously a stub added in Add a pluggable radial ContextMenu component #266) - wired up to real pivotable/pivot chart-level state in @chart-io/core, mirroring how zoomable/zoom back <Treemap zoomable>.
  • Pass an odd-length colors palette (3, 5, ...) - e.g. for a correlation matrix - and the color scale centers on 0 instead of the data's own midpoint, so equal-and-opposite values get equally saturated, opposite colors.
  • Includes Storybook stories + MDX docs (Charts/Miscellaneous/Heatmap) covering the basic grid, Canvas rendering, the pivot toggle, a Desk Occupancy example matching the notebook, and a correlation-matrix example (diverging colors), plus unit tests for <Heatmap>, <HeatmapAxes>, <HeatmapPlot> and the new pivotable/pivot store state.

Test plan

  • pnpm --filter @chart-io/core types / pnpm --filter @chart-io/react types (tsc --noEmit) pass
  • pnpm --filter @chart-io/core lint / pnpm --filter @chart-io/react lint (eslint) pass with no errors
  • Full @chart-io/core unit test suite passes (270/270)
  • Full @chart-io/react unit test suite passes (471/471), including Heatmap.unit.tsx, HeatmapAxes.unit.tsx, HeatmapPlot.unit.tsx and the createPivotAction/pivotable/pivot tests
  • Manual check in Storybook (Charts/Miscellaneous/Heatmap) - Basic, Canvas, Pivotable, Desk Occupancy and Correlation Matrix stories
  • Chromatic baselines for the new stories accepted

@changeset-bot

changeset-bot Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7be174c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@chart-io/core Minor
@chart-io/react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@IPWright83
IPWright83 marked this pull request as ready for review August 17, 2026 20:42
Comment thread packages/react/src/lib/components/Plots/Heatmap/Heatmap.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/Heatmap.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/Heatmap.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/Heatmap.unit.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/HeatmapPlot.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/Heatmap.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/Heatmap.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap/HeatmapPlot.tsx Outdated
Comment thread packages/react/src/lib/components/Plots/Heatmap.stories.tsx

Copy link
Copy Markdown
Owner Author

Thanks for the detailed review, and for pulling the notebook source - that made everything unambiguous. Pushed a rework addressing all of it:

  • Rebuilt around the actual pivot mechanic, not clustering. The notebook shares one d3 update pattern across three layouts: the full grid, each row collapsed into a stacked horizontal bar (column axis → linear), each column collapsed into a stacked vertical bar (row axis → linear). <Heatmap pivotable> now does exactly that via pivot: "grid" | "rows" | "columns".
  • Uses real <XAxis>/<YAxis> with band scales (switching to a linear scale over value on whichever axis is pivoted away) instead of hand-rolled band positions and text labels. <Heatmap> still stays a single self-contained component though (like <Treemap>) - it composes <XYChart> + a new <HeatmapAxes> + <HeatmapPlot> internally, so nobody has to wire that up themselves.
  • Pivot state lives in the store, mirroring the existing zoom/zoomable pattern (pivotable/pivot on IChartState, setPivotable/setPivot actions) - a usePivot hook and a <PivotControl> overlay (mirroring <ZoomBreadcrumb>) sit on top of that.
  • Dropped the padding prop - cells are sized straight from the band scale now, same as everything else.
  • Added a real gradient legend (<HeatmapLegend>), and colors: an odd-length palette (3, 5, ...) is now treated as diverging and centers on 0 rather than the data's midpoint - added a Correlation Matrix story to show that off, plus a like-for-like recreation of the notebook's own desk-occupancy dataset so the two can be compared directly.
  • One thing worth flagging: getting <HeatmapAxes> right surfaced a real render/dispatch loop - if the same component both renders <XAxis>/<YAxis> and reads the scale those axes register, it re-renders itself into re-registering that scale forever. <HeatmapAxes> only reads pivot/data now, never the scale it produces; <HeatmapPlot> reads the scale but doesn't touch the axis elements. Also merged in master (Sankey/Chord) and swapped onto the new shared RectsPlot rather than keeping my own duplicate of it.

Still on colors - I kept it as an optional override (defaulting fully to the theme, 2-stop background→series-color) rather than removing it, since without it there's no way to express a diverging palette. Let me know if you'd rather see that done differently.


Generated by Claude Code

@IPWright83
IPWright83 force-pushed the claude/heatmap-grouping-animations-nxav0v branch from 153dab6 to d8f24e4 Compare August 25, 2026 17:34
@IPWright83 IPWright83 changed the title Add Heatmap plot with animated row/column grouping Add Heatmap plot with a grid ↔ stacked-bar pivot Aug 25, 2026
claude added 7 commits August 25, 2026 21:00
Adds a self-contained <Heatmap> chart (rows/columns/value grid, colored
along a sequential scale) alongside a new generic <CellsPlot> primitive,
following the same layout-hook + Canvas/SVG composition pattern used by
<Treemap>/<Dendrogram>.

Cells are keyed by their row/column pair, so setting rowGroupBy/columnGroupBy
plus toggling rowsGrouped/columnsGrouped reorders that axis to cluster
matching groups together - existing cells transition smoothly to their new
position instead of being re-created, animating the regrouping.
The previous implementation misread the linked Observable notebook as a
row/column clustering demo. The real notebook toggles between three
layouts sharing one d3 update pattern: the full grid, each row collapsed
into a stacked horizontal bar (column axis -> linear), and each column
collapsed into a stacked vertical bar (row axis -> linear). This replaces
the old rowGroupBy/rowsGrouped clustering entirely with that pivot.

- Heatmap now composes <XYChart> + <HeatmapAxes> (owns the pivot-driven
  <XAxis>/<YAxis>) + <HeatmapPlot> (cells + legend), rather than being a
  fully self-contained Treemap-style plot with its own hand-rolled band
  positions and text labels.
- Adds chart-level pivot state to the core store (pivotable/pivot,
  mirroring the existing zoom pattern), a usePivot hook, and a
  <PivotControl> overlay (mirroring <ZoomBreadcrumb>) so <Heatmap
  pivotable> stays a single drop-in component.
- Cells are always a dense row x column cross-product (missing
  combinations default to 0) so every cell survives a pivot change and
  transitions to its new position/size instead of being re-created.
- Adds a real gradient <HeatmapLegend>, and centers the color scale on 0
  for an odd-length ("diverging") palette - e.g. a correlation matrix -
  rather than the data's own midpoint.
- <HeatmapAxes> deliberately only reads pivot/data, never the scale it
  registers, since <HeatmapPlot> reading that same scale and re-rendering
  the axis from there is a render/dispatch loop (root-caused via a
  standalone repro against <XAxis>+<Bar>, which doesn't hit it since its
  axis isn't re-created by the thing reading its scale).
- Stories: a pivotable GDP-by-sector example, a like-for-like recreation
  of the notebook's desk-occupancy dataset, and a diverging correlation
  matrix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc
master's Sankey work landed its own generic rect-join primitive
(RectsPlot) while this branch had its own equivalent (CellsPlot, added
before that merge). Drop the duplicate and use the shared one instead.
Wires createPivotAction (previously a stub) to the real pivotable/pivot
store state - it now cycles a pivotable Heatmap through grid/rows/columns
and is disabled otherwise. Removes <PivotControl>, the bespoke overlay
built before ContextMenu landed, since the chart's right-click menu now
covers the same job.
CI failed with the item not found right after the opening click - the
imperative D3 render inside ContextMenu's effect apparently doesn't
always settle within a single synchronous act() flush on a busier
runner. Poll briefly instead of assuming it's there immediately.
…Menu

Master's ParallelCoordinates PR changed <ContextMenuOverlay> to open on
a native "contextmenu" (right-click) event scoped to the background,
rather than any left "click" - so the Heatmap pivot tests/stories,
written against the old click-based overlay, could no longer find the
menu at all. Switch them to fireEvent.contextMenu to match.
@IPWright83
IPWright83 force-pushed the claude/heatmap-grouping-animations-nxav0v branch from 346f618 to 3b7d7b1 Compare August 25, 2026 21:11
Per review: pivot now stores which axis (if any) is collapsed into a
linear scale - "x" | "y" | undefined for the full grid - rather than
"grid" | "rows" | "columns". Generalizes the chart-level pivot/pivotable
state and the ContextMenu's "Pivot" action so they aren't tied to
Heatmap's own rows/columns vocabulary; other chart types can reuse the
same store shape and action.

Copy link
Copy Markdown
Owner Author

Addressing two open review comments (couldn't reply inline directly - the force-pushed rebases seem to have made GitHub treat their original diff context as invalid for direct replies):

  1. "Could we instead pivot on the field (the x, y, or undefined for the grid)" - Done. IPivot is now "x" | "y", with pivot: undefined meaning the full grid, rather than "grid" | "rows" | "columns". It's generic to which axis is collapsed into a linear scale, not tied to <Heatmap>'s own rows/columns field names, so the pivotable/pivot store state and the <ContextMenu>'s "Pivot" action (which lives in @chart-io/core, not Heatmap-specific) can be reused by other chart types later. <Heatmap>'s own heatmapAxisFor maps "x" → the column axis collapses (each row becomes a horizontal bar) and "y" → the row axis collapses (each column becomes a vertical bar) - same behavior as before, just renamed at the state/action layer.

  2. "We're going to do the pivot a different way, take this out for now" (on <PivotControl>) - Done. <PivotControl> has been removed entirely. The pivot is now switched via the "Pivot" action on the chart's right-click <ContextMenu> instead, since that's already wired up to the store for every <XYChart>/<RadialChart> - no need for a bespoke overlay component.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Re the "UI Tests"/"UI Review" failure statuses on this and prior commits: the underlying chromatic GitHub Actions job itself completes successfully each time (build/upload works fine) - the "failure" is Chromatic's own status check reporting that the new/changed Heatmap stories (Basic, Canvas, Pivotable, Desk Occupancy, Correlation Matrix) have no accepted baseline yet, which is expected for brand-new stories and already called out as an open item in this PR's test plan. There's no code fix for that - it just needs a human to review and accept the baselines at the Chromatic review link in the check. I'll keep watching for any other CI issues, but this specific status is blocked on that review rather than on anything in the PR.


Generated by Claude Code

Per review: the gradient no longer renders as a fixed element under the
plot. It's now registered via a new useColorLegend hook (mirroring
useSizeLegend/<ZAxis>) and rendered as a <ColorLegend> at the bottom of
the shared <Legend> box, docking/dragging with the rest of the legend.

Adds colorLegend/setColorLegend/clearColorLegend store state in
@chart-io/core, alongside the existing sizeLegend, and factors it into
legend visibility. Removes the now-redundant standalone <HeatmapLegend>.

Copy link
Copy Markdown
Owner Author

Moved the color scale to be embedded at the bottom of the chart's <Legend>, matching the <ZAxis> size-legend convention, instead of rendering as a fixed gradient bar under the plot:

  • Added useColorLegend (mirroring useSizeLegend) plus colorLegend/setColorLegend/clearColorLegend store state in @chart-io/core, alongside the existing sizeLegend.
  • Added <ColorLegend> under LegendOverlay/Legend/, rendered by <Legend> right alongside <SizeLegend> with the same divider styling.
  • <HeatmapPlot> now registers the palette/domain via useColorLegend (only in the full grid layout) instead of rendering its own <HeatmapLegend>, which has been removed.
  • Updated tests/stories/docs accordingly.

Generated by Claude Code

The Pivotable story clicked the "Pivot" context-menu item synchronously
right after opening the menu, but it renders imperatively via D3 inside
a useEffect and can take an extra tick to appear - crashing with
"Cannot read properties of undefined (reading 'querySelector')" under
Chromatic's real-browser capture. Poll for it the same way the
equivalent Heatmap.unit.tsx test already does.

Also add a legendPosition prop (XYChart -> LegendOverlay) so <Heatmap>
can dock its Legend at "SE" by default instead of <XYChart>'s usual
"E". This required changing defaultChartState.legend.position from a
hardcoded "E" to undefined, since <LegendOverlay> reads that raw store
value (not the "E"-defaulting selector) specifically to distinguish
"never dragged" from "dragged to E" - with it pre-seeded to "E", the
dragged-position check always short-circuited and the new prop was
never consulted.

Copy link
Copy Markdown
Owner Author

Pushed 9f0069d with two fixes:

  1. Chromatic crash fixed - the Pivotable story's play function clicked the "Pivot" context-menu item synchronously right after opening the menu, but the menu renders imperatively via D3 inside a useEffect and can take an extra tick to appear under a real browser. That's exactly the TypeError: Cannot read properties of undefined (reading 'querySelector') you saw. Now it polls briefly for the item before clicking, mirroring the same fix already in Heatmap.unit.tsx.

  2. Legend now defaults to SE for <Heatmap> - added a legendPosition prop (XYChart → LegendOverlay) so <Heatmap> docks its Legend at "SE" by default instead of <XYChart>'s usual "E", with the option to override via the prop. This surfaced a real bug along the way: <LegendOverlay> distinguishes "never dragged" from "dragged to E" by reading the raw store value directly, but defaultChartState.legend.position was hardcoded to "E" rather than undefined, so that check always short-circuited and any default-position prop was silently ignored. Fixed by seeding it undefined instead - chartSelectors.legend.position's own ?? "E" fallback still covers every other caller.

Full core (291) and react (505) suites pass, along with tsc --noEmit and eslint on both packages.


Generated by Claude Code

@IPWright83

Copy link
Copy Markdown
Owner Author

Could we get a stories with the pivoted data, and a story for the menu with the pivot/column or row selection

…action

Per PR feedback: adds "Pivoted to Rows"/"Pivoted to Columns" stories showing
each layout at rest (rather than mid-cycle, as in the existing Pivotable
story), plus a "Pivot Context-Menu Action" story that leaves the right-click
menu open so the "Pivot: ..." item is visible for review - the same
at-rest-menu convention already used by ContextMenuOverlay's own stories.

Factored the existing findPivotItem/cyclePivot logic out of the Pivotable
story's play function into shared module-level helpers so all four stories
reuse the same polling-based menu interaction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 1a88a86 with three new stories:

  • "Pivoted to Rows" / "Pivoted to Columns" - each layout shown at rest (the play function pivots once/twice and stops there), rather than mid-cycle like the existing "Pivotable" story.
  • "Pivot Context-Menu Action" - right-clicks the chart and leaves the menu open (rather than selecting anything), so the "Pivot: ..." action is visible for review alongside the other menu items - same at-rest-menu convention <ContextMenuOverlay>'s own stories already use.

Verified all four Heatmap stories with play functions (including the two pre-existing ones) against a real headless-Chromium build of Storybook - no thrown exceptions, and the DOM state after each play function matches what's expected (varying cell widths/heights per pivot, the "Pivot: x" menu item visible and correctly labelled).


Generated by Claude Code

@IPWright83

Copy link
Copy Markdown
Owner Author

When pivoting, all the sections contributing to the bar or column should be the same colour. We need to lose the colour part of the scale.

The pivot menu can be tested in isolation as a menu story, rather than via the heatmap. But I expected a clear pivot icon (currently looks like a refresh? With a 2nd layer of segments like an outer tier in a stacked donut for rows or columns - I'm not seeing that)

…u story

Per PR feedback:

- Once a Heatmap is pivoted, every cell contributing to a row's/column's
  stacked bar now renders in the same flat color (the palette's own "high"
  stop) instead of the value-driven gradient - a cell's height/width already
  encodes its value once it's a bar segment, so coloring it by value too just
  looked like an arbitrary per-segment gradient.
- Redesigned the "Pivot" context-menu icon: it previously reused a generic
  two-arrow "refresh" glyph shared with no obvious connection to the action.
  It's now two separate cells folding into one bar divided into their
  segments, matching what the action actually does.
- Moved the "Pivot" menu demo out of the Heatmap stories into
  ContextMenuOverlay's own stories (as "Pivot Action") - the action is
  enabled purely by a chart's `pivotable` flag, so it doesn't need a full
  Heatmap to demonstrate, just `usePivot(true)` on any pivotable chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 71d66af addressing all three points:

  1. Flat color when pivoted - found the actual bug: useHeatmapLayout's cell coloring always ran every cell's value through the value-based gradient, regardless of pivot state, so each segment of a stacked bar kept its own shade. Now, once pivoted, every cell renders in one flat color (the palette's "high" stop, so a custom colors prop still carries through) - the bar's height/width already encodes the value, so per-segment shading was redundant. Added a unit test asserting all segments share one fill once pivoted.

  2. Pivot menu in isolation - moved it out of the Heatmap stories into ContextMenuOverlay's own stories as "Pivot Action". Turns out pivotable is dispatched by a small hook (usePivot) rather than being a generic chart prop, so the isolated story just calls that hook directly on a plain <Bar> chart - no <Heatmap> needed to see the menu item. Also fixed a stale doc line in ContextMenu.mdx that still called Pivot "a stub" from before it was wired up.

  3. Icon - replaced the two-curved-arrows "refresh" glyph with two separate cells folding into one bar divided into their segments, matching what the action actually does. This is a first pass at the icon - happy to iterate further if the shape doesn't land, since icon design is inherently a matter of taste. I verified it renders distinctly from the other menu icons at the real 18px size (screenshot below).

Verified all three changes against a real headless-Chromium build of Storybook: the pivoted stories now show a single flat fill across all cells, and the isolated Pivot Action story opens correctly with the new icon visible. Full core (291) and react (506) suites pass, tsc --noEmit/eslint clean on both packages.


Generated by Claude Code

Per feedback: reverted the "Pivot" context-menu icon back to the original
two-curved-arrows glyph, and added a new general IContextMenuItem.activeIcon
capability instead of replacing it outright - an alternate icon that
crossfades in over the resting one while a segment is hovered/active, so an
action can reveal more detail (here, two bars hinting at the stacked-bar
result) without cluttering its resting state. createPivotAction now sets
this to a small two-bars icon.

Found and fixed a real race along the way: a menu's icon fades in via its
own d3 transition when the menu first opens, which would silently overwrite
a hover's crossfade if the hover landed within that same window (icon stuck
fully opaque even while "active"). Fixed with `.interrupt()` before setting
the hover-driven opacity, with a regression test covering it.

Also added a "Pivot Action (Hovered)" story to ContextMenuOverlay's stories
so the hovered state is easy to review without a human having to hold the
mouse over it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 104f83b, per your sketch:

  • Restored the original two-curved-arrows "Pivot" icon.
  • Added a new general IContextMenuItem.activeIcon field (not Pivot-specific) - an alternate icon that crossfades in over the resting one while a segment is hovered, so any action can reveal more detail without cluttering its resting state. createPivotAction sets this to two small bars, hinting at the stacked-bar result.
  • Added a "Pivot Action (Hovered)" story to ContextMenuOverlay's stories so the hovered state renders for review without anyone needing to actually hold the mouse over it.

Along the way I found and fixed a real race: a menu's icon fades in via its own animation right when the menu opens, and if a hover landed inside that same ~220ms window, it would silently overwrite the crossfade once that animation finished - the segment would end up stuck showing the resting icon even while "active". Fixed with a .interrupt() call, with a regression test that fails without it and passes with it.

Screenshot of the hovered state:

Resting: arrows icon, unchanged from before.
Hovered: crossfades to two bars, matching your sketch's "something over the active segment" idea.

Full core (294) and react (506) suites pass, tsc --noEmit/eslint clean on both packages.


Generated by Claude Code

The "Pivoted to Columns"/"Pivoted to Rows" stories showed visible white
seams and notches cutting across otherwise-solid bars - three compounding
causes, all specific to a pivoted Heatmap's edge-to-edge stacked segments
(never visible in the grid, where cells aren't adjacent):

1. Each segment's start/end were computed by scaling its cumulative
   previous/current values independently, which could each land on a
   different fractional pixel even though they're mathematically
   contiguous - two SVG rects meeting at a fractional boundary render with
   a hairline anti-aliased seam between them. Fixed by rounding both sides
   through the same function, so adjacent segments always resolve to the
   identical pixel edge.
2. `cornerRadius` was applied to every segment individually, leaving a
   rounded notch at every internal segment boundary once they're packed
   edge-to-edge into one bar. Fixed by zeroing it out once pivoted, where
   cells no longer read as individually-rounded and the corner rounding
   only makes sense on the grid layout.
3. That second fix initially had no visible effect - `<RectsPlot>` only
   ever set `rx`/`ry` on newly-entering rects, never on ones already
   rendered. Since Heatmap's cells persist (and transition) across a pivot
   rather than being recreated, a changed `cornerRadius` prop was silently
   ignored on every cell already on screen. Fixed by also applying it on
   the merged enter+update selection.

Verified against a real headless-Chromium build of Storybook, comparing
before/after screenshots at each step to isolate which of the three was
actually responsible before fixing all three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 13bed0c fixing the rendering artifacts on pivoted bars (the "Industry" bar screenshot). Found three compounding causes, all specific to a pivoted Heatmap's edge-to-edge stacked segments (never visible in the grid, since cells aren't adjacent there):

  1. Fractional-pixel seams - each segment's start/end were scaled independently from cumulative sums, which can each land on a different fractional pixel even though mathematically contiguous. Two SVG rects meeting at a fractional boundary render with a hairline anti-aliased seam. Fixed by rounding both sides through the same function so adjacent segments always share the identical pixel edge.
  2. Rounded-corner notches - cornerRadius was applied to every segment individually, leaving a rounded notch at every internal boundary once packed edge-to-edge into one bar. Fixed by zeroing it out once pivoted.
  3. That fix had no effect at first - <RectsPlot> only ever set rx/ry on newly-entering rects, never on ones already on screen. Since Heatmap's cells persist and transition across a pivot rather than being recreated, a changed cornerRadius was silently ignored on every existing cell. Fixed by also applying it on the merged enter+update selection - a real, separate bug in RectsPlot itself, not Heatmap-specific.

I isolated each cause with before/after screenshots against a real headless-Chromium build of Storybook before fixing all three (screenshot attached to confirm the "Industry" bar specifically). Full react suite (508 tests, +4 new covering each fix) passes, tsc --noEmit/eslint clean.


Generated by Claude Code

Per feedback: the previous activeIcon crossfade just swapped the icon
glyph inside the wedge - not the "extra nested segment"/"outer tier" the
original sketch called for. Replaced IContextMenuItem.activeIcon with
activeSegments (a general capability, not Pivot-specific): the number of
equal-width mini arcs to reveal as an extra ring band just outside a
segment's own arc while it's hovered, echoing a stacked donut's outer
tier. createPivotAction sets this to 2, for the two collapsed-axis
layouts (rows/columns) pivoting cycles between alongside the grid.

The base "Pivot" icon (the two curved arrows) stays as-is at rest and
while hovered - only the new outer band appears/disappears now, so
there's no icon crossfade to speak of. Also fixes a real bug this exposed:
the outer band would overflow the menu's own <svg> viewBox and get
clipped, since it draws outside the ring's usual radius+thickness bounds
- widened the viewBox to always fit it, whether or not any item actually
uses activeSegments.

Verified against a real headless-Chromium build of Storybook: the band
renders as two visually distinct arcs above the hovered Pivot wedge, and
is absent everywhere else and at rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc
Per feedback: left-clicking any cell now advances the pivot by one step
(grid -> rows -> columns -> grid), the same cycle the right-click
<ContextMenu>'s "Pivot" action already drives - a quicker path to the same
thing, not a different one. A no-op unless the chart has opted in via
`pivotable`, same as the menu action's own disabled state.

Extracted the cycle order itself (previously private to createPivotAction)
into a shared `nextPivot` utility in @chart-io/core, so both triggers stay
in agreement rather than each keeping their own copy.

Verified against a real headless-Chromium build of Storybook: clicking a
grid cell pivots it live, with no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 5ce8602 and 53e3bcf addressing both points:

1. Real outer-tier segment, not an icon swap (5ce8602) - you were right, the previous version just crossfaded the icon glyph. Replaced IContextMenuItem.activeIcon with a general activeSegments capability: the number of equal-width mini arcs to reveal as an extra ring band just outside a segment's own arc while it's hovered, matching a stacked donut's outer tier from your sketch. createPivotAction sets this to 2 (for the two collapsed-axis layouts pivoting cycles between). The base "Pivot" icon (the two arrows) stays as-is at rest and on hover - only the new band appears/disappears now. Screenshot attached showing it live.

2. Pivot on left-click of a datum (53e3bcf) - left-clicking any Heatmap cell now advances the pivot by one step (grid -> rows -> columns -> grid), the same cycle the context-menu "Pivot" action drives - a quicker path to the same thing rather than a separate mechanism. Stays a no-op unless pivotable is set. Extracted the cycle order into a shared nextPivot utility so both triggers agree.

Verified both against a real headless-Chromium build of Storybook (not just Jest) - the outer band renders correctly and is absent at rest, and left-clicking a cell pivots it live with no console errors. Full core (295) and react (510) suites pass, tsc --noEmit/eslint clean on both packages.


Generated by Claude Code

Per Chromatic review feedback:

- Renamed the Heatmap's large-dataset story away from "Desk Occupancy" -
  it's now "Weekly Pattern", with its value field renamed from
  "occupancy" to "level", and the Observable-notebook references dropped
  from both the story and the docs.
- The "Pivot" action was missing from the per-datum `<ContextMenu>`
  (`getDefaultDatumItems`) - a pivotable chart's cells are themselves the
  data points a left-click opens that menu on, so it belongs there too.
  Needed a signature change (now takes `state`, like the background
  items already do) to build its disabled/label state.
- `activeSegments` was a bare count with no way to show what each mini
  segment actually meant. Changed it to an array of icons (one per mini
  segment) instead of a count - `createPivotAction` now uses a small
  horizontal-bar icon for "rows" and a vertical-bar icon for "columns",
  rendered centered in each mini segment via the same centroid math the
  main icon already uses.
- Added a "Pivot Action (Cancel)" story to `ContextMenuOverlay`'s
  stories - pre-pivots to "y" on mount so the "Pivot" item's label reads
  "Pivot: grid", showing the reset-to-grid step of the cycle. Kept it to
  the same plain-Bar-chart-in-the-background convention as the other
  Pivot stories, no `<Heatmap>` needed.

Verified against a real headless-Chromium build of Storybook: the datum
menu now shows an enabled "Pivot" item, the outer band shows two visually
distinct icons per mini segment, and the cancel story's label reads
correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed c5d0f6d addressing all four points:

  1. Desk Occupancy references removed - the large-dataset Heatmap story is now "Weekly Pattern" with its value field renamed occupancy -> level, and the Observable-notebook mentions dropped from both the story and the docs.
  2. Pivot added to the datum menu - getDefaultDatumItems now includes it, same as the background menu. Needed a small signature change (now takes state) so it can compute its disabled/label state. Screenshot attached showing it enabled in the "Click a Bar" story.
  3. Icons on the 2nd-tier mini segments - activeSegments was a bare count with nothing to distinguish one mini segment from another. Changed it to an array of icons instead - a horizontal-bar icon for "rows" and a vertical-bar icon for "columns", each centered in its own mini segment. Screenshot attached.
  4. "Cancel pivot" story added - "Pivot Action (Cancel)" in ContextMenuOverlay's stories, pre-pivoted to "y" on mount so the label reads "Pivot: grid". Same plain-Bar-chart convention as the other Pivot stories, no <Heatmap> in the background.

Verified all four against a real headless-Chromium build of Storybook. Full core (296) and react (510) suites pass, tsc --noEmit/eslint clean on both packages.


Generated by Claude Code

The single-rectangle pivotRows/pivotColumns icons read poorly at their
original size. Redesigned each as three bars of increasing size capped
with arrowheads, so they read as a growing bar/column chart, and gave
the outer active-segment band more room (ACTIVE_BAND_THICKNESS 12->26,
MINI_ICON_SIZE 9->22, ACTIVE_BAND_GAP 3->4) - this band is a menu hint
rather than real data, so it isn't space-constrained like the ring it
sits outside of.

Once pivot is already "y" (columns), selecting Pivot again cycles back
to the grid rather than collapsing further. createPivotAction now swaps
the columns activeSegment for a new pivotCancel (X) icon in that state,
so the menu shows the same two nested items with one replaced by a
cross rather than two icons that no longer make sense. Updated the
PivotActionCancel story to hover the Pivot item automatically so this
state is visible without manual interaction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed faf11f0, addressing the latest icon-legibility feedback:

Redesigned the Pivot action's mini icons
The single-rectangle pivotRows/pivotColumns icons weren't reading clearly at their size. Each is now three bars of increasing length/height capped with arrowheads, so they read as a growing bar/column chart rather than an ambiguous block. Since the outer active-segment band is a menu hint (not real data), it's no longer space-constrained to match the main ring's proportions — ACTIVE_BAND_THICKNESS went 12→26px and MINI_ICON_SIZE 9→22px, with ACTIVE_BAND_GAP nudged 3→4px.

Added a cancel state
Once pivot === "y" (already collapsed to columns), selecting Pivot again cycles back to the grid rather than collapsing further. createPivotAction now swaps the columns slot for a new pivotCancel (✕) icon in that state, so hovering shows the same two nested items with one replaced by a cross — matching what pivoting back to the grid actually does. The PivotActionCancel story now hovers the Pivot item automatically in its own play function, so the state is visible without manual interaction.

Verified both against a real headless-Chromium render of the built Storybook (not just Jest/jsdom) — screenshots attached to the conversation. Full suites green: core 297 tests, react 510 tests, typecheck and lint clean (a pre-existing moduleResolution=node10 deprecation warning in core's typecheck is unrelated to this change — reproduces on c5d0f6d too).


Generated by Claude Code

Plain bars alone read clearly as a growing bar/column chart - the
arrowheads added visual noise without adding clarity, verified against
a real headless-Chromium render of Storybook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 2171c0f - removed the arrowheads from the pivotRows/pivotColumns icons, leaving just the plain bars. They read clearly as a growing bar/column chart on their own. Verified against a real headless-Chromium render of Storybook, screenshots attached to the conversation. Core suite still green (297 tests) and lint clean.


Generated by Claude Code

Fixes four issues from review:

- The pivot transition looked janky because HeatmapPlot recreated its
  cell handlers (and an inline `cursor` prop) on every render, not just
  when the pivot actually changed. Since RectsPlot's D3 join restarts
  its whole transition whenever any of those props change identity, an
  entirely unrelated re-render (e.g. hovering a cell while the grid was
  still animating into a bar chart) would interrupt and restart the
  transition mid-flight - producing a visible stutter, confirmed via a
  real-browser timing trace showing two overlapping ~250ms transitions
  instead of one. Stabilized the handlers with a "latest ref" pattern
  and memoized `cursor`, so only a real pivot/data change now retriggers
  the transition.

- Left-clicking a Heatmap cell already advanced the pivot; it now also
  opens the same datum `<ContextMenu>` every other interactive plot's
  left-click wires up (via the shared `useDatumContextMenu` hook), so
  the click that just pivoted also exposes the rest of the datum
  actions rather than the instant cycle being the only way to reach them.

- Thickened the `pivotRows`/`pivotColumns`/`pivotCancel` bars and inset
  them further from the mini-icon's edges, per feedback that they were
  still a bit thin/cramped even after the earlier arrowhead removal.

- The active-segments band sits a few pixels outside the main wedge;
  moving the pointer from one to the other crossed that (unpainted) gap
  and read as leaving the item, hiding the band before it was reached.
  Added an invisible hover-bridge spanning the gap and band, sharing the
  wedge's own hover state, so crossing between them no longer drops it.

All four verified against a real headless-Chromium render of Storybook.
Full suites green (core 298 tests incl. a new bridge-continuity
regression test, react 510), typecheck and lint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 86b39fb, addressing four issues from this round of review:

1. Janky pivot animation
Root-caused via a real-browser timing trace: HeatmapPlot recreated its cell handlers (onMouseOver/onMouseOut/onClick) and an inline cursor prop on every render, not just when the pivot changed. <RectsPlot>'s D3 join restarts its whole position transition whenever those props change identity, so an entirely unrelated re-render — e.g. hovering a cell while the grid was still animating into a bar chart — would interrupt and restart the transition mid-flight. A width trace confirmed two overlapping ~250ms transitions instead of one (a plateau-then-slow-decay stutter). Fixed by stabilizing the handlers with a "latest ref" pattern and memoizing cursor, so only a real pivot/data change retriggers the transition. Confirmed clean via the same trace method - now a single smooth decay start-to-finish.

2. Clicking a bar/column now also shows the menu
Left-clicking a Heatmap cell already advanced the pivot; it now also opens the same datum <ContextMenu> every other interactive plot's left-click wires up, via the shared useDatumContextMenu hook - same click, same instant pivot, plus the rest of the datum actions.

3. Thicker/more padded pivot icons
Increased the pivotRows/pivotColumns/pivotCancel bar stroke-width and inset them further from the mini-icon's edges.

4. Fixed the hover gap between the Pivot wedge and its sub-icon band
The active-segments band sits a few pixels outside the main wedge; moving the pointer from one to the other crossed that unpainted gap, which read as leaving the item and hid the band before it was reached. Added an invisible hover-bridge spanning the gap, sharing the wedge's own hover state - verified via a real-browser event trace and a new regression test.

All four verified against a real headless-Chromium render of Storybook (screenshots attached to the conversation). Full suites green - core 298 tests (including a new bridge-continuity regression test), react 510 - typecheck and lint clean.


Generated by Claude Code

Left-click already opened the cell's datum menu (with "Pivot" as one
of its items) alongside the existing direct pivotTo(nextPivot(pivot))
call - but firing both on the same click meant the layout had already
changed by the time the menu was there to choose from, since the
instant pivot wasn't waiting on anything. Reported as "it pivots before
I have a chance to select a menu option".

Removed the direct pivot-on-click - selecting "Pivot" from the menu is
now the only thing that changes the layout from a cell click, matching
the right-click background menu and every other interactive plot's
left-click behavior. Verified in a real browser: the chart stays in
the grid while the menu is open, and only pivots once "Pivot" is
actually selected.

Updated Heatmap's docs/comments and the two Heatmap.unit.tsx tests that
asserted the old instant-pivot behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABBKPHYasriAGx8avvLgtc

Copy link
Copy Markdown
Owner Author

Pushed 7be174c, fixing the race you hit: tapping a bar on the Pivotable story pivoted before you could pick a menu option.

The previous commit added "click opens the datum menu" alongside the existing "click also pivots instantly" behavior - but both firing on the same click meant the layout had already changed by the time the menu was there to choose from.

Removed the direct pivot-on-click entirely. Now a cell's left-click only opens its datum menu; selecting "Pivot" from that menu is what actually changes the layout - the same as the right-click background menu, and consistent with every other interactive plot's left-click behavior.

Verified in a real browser: after clicking a cell, the chart stays in the grid (uniform cell widths) with the menu open showing "Pivot: x" - only clicking that menu item actually pivots (varied widths, menu closes). Updated the two Heatmap.unit.tsx tests that asserted the old instant-pivot behavior, plus the docs/comments describing it. Full suites green (react 511, core 298), typecheck and lint clean.


Generated by Claude Code

This branch has not been deployed

No deployments
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