Add Heatmap plot with a grid ↔ stacked-bar pivot - #258
IPWright83 wants to merge 21 commits into
Conversation
🦋 Changeset detectedLatest commit: 7be174c The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
|
Thanks for the detailed review, and for pulling the notebook source - that made everything unambiguous. Pushed a rework addressing all of it:
Still on Generated by Claude Code |
153dab6 to
d8f24e4
Compare
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.
346f618 to
3b7d7b1
Compare
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.
|
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):
Generated by Claude Code |
|
Re the "UI Tests"/"UI Review" failure statuses on this and prior commits: the underlying 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>.
|
Moved the color scale to be embedded at the bottom of the chart's
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.
|
Pushed
Full core (291) and react (505) suites pass, along with Generated by Claude Code |
|
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
|
Pushed
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 |
|
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
|
Pushed
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, 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
|
Pushed
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 Screenshot of the hovered state: Resting: arrows icon, unchanged from before. Full core (294) and react (506) suites pass, 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
|
Pushed
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, 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
|
Pushed 1. Real outer-tier segment, not an icon swap ( 2. Pivot on left-click of a datum ( 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, 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
|
Pushed
Verified all four against a real headless-Chromium build of Storybook. Full core (296) and react (510) suites pass, 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
|
Pushed Redesigned the Pivot action's mini icons Added a cancel state 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 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
|
Pushed 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
|
Pushed 1. Janky pivot animation 2. Clicking a bar/column now also shows the menu 3. Thicker/more padded pivot icons 4. Fixed the hover gap between the Pivot wedge and its sub-icon band 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
|
Pushed 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 Generated by Claude Code |
Summary
<Heatmap>chart: a grid of cells - one perrows/columnscombination in the data - colored byvaluealong 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.pivotableto 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.<ContextMenu>(previously a stub added in Add a pluggable radial ContextMenu component #266) - wired up to realpivotable/pivotchart-level state in@chart-io/core, mirroring howzoomable/zoomback<Treemap zoomable>.colorspalette (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.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 newpivotable/pivotstore state.Test plan
pnpm --filter @chart-io/core types/pnpm --filter @chart-io/react types(tsc --noEmit) passpnpm --filter @chart-io/core lint/pnpm --filter @chart-io/react lint(eslint) pass with no errors@chart-io/coreunit test suite passes (270/270)@chart-io/reactunit test suite passes (471/471), includingHeatmap.unit.tsx,HeatmapAxes.unit.tsx,HeatmapPlot.unit.tsxand thecreatePivotAction/pivotable/pivottestsCharts/Miscellaneous/Heatmap) - Basic, Canvas, Pivotable, Desk Occupancy and Correlation Matrix stories