Skip to content

Commit cc5da17

Browse files
authored
Merge pull request #702 from nteract/fix-hierarchical-charts
Fix hierarchical chart rendering, hover clearing, and docs updates
2 parents 77697c6 + 64765d1 commit cc5da17

38 files changed

Lines changed: 498 additions & 115 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ A producer-consumer coordination system for cross-highlighting, brushing-and-lin
3535
- Tooltip positioned above the hovered point with colorBy label
3636
- `colorBy`, `fieldLabels`, `cellSize`, `pointRadius`, `showLegend` props
3737

38+
### Fixed
39+
40+
- **Hover state persisting on mouseout** — tooltip and linked highlight now reliably clear when the mouse leaves the chart area. Added a `mouseLeave` handler on the Frame wrapper (inside `TooltipProvider`) that clears both the tooltip and any active `customHoverBehavior` selection.
41+
- **`createStore` updater bug** — the internal `set` function in `createStore` passed itself instead of the current state to updater callbacks (`fn(set)``fn(state)`). This caused `clearClause` in the SelectionStore to silently fail (accessing `.selections` on a function), so linked hover selections were never actually cleared. `setClause` worked by coincidence because `new Map(undefined)` produces an empty map.
42+
- **Treemap/CirclePack hover overlays** — fixed broken hover overlays for all area-based NetworkFrame types (treemap, circlepack, partition, chord). After the Mark component removal in v3, overlay entries spread `.props` from node generators which produced `<path>` elements without a `d` attribute. Now passes `renderElement` directly for `React.cloneElement`.
43+
- **CirclePack rendering as force-directed** — CirclePack defaulted all circles to 10px diameter because the node size accessor ignored d3 pack layout's `r` property. Now uses `nodeSizeAccessor: (d) => d.r || 5`.
44+
3845
### Removed
3946

4047
- **FacetController** — replaced entirely by `LinkedCharts`. The `cloneElement`-based approach had no HOC support and only worked with direct Frame children.

CLAUDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,14 +344,18 @@ Props: `data` (TNode, required — single root node with children),
344344
```
345345

346346
#### Treemap
347-
Space-filling rectangular hierarchy visualization.
347+
Space-filling rectangular hierarchy visualization. Labels are centered in cells.
348+
Hover shows ancestor breadcrumb path (grandparent → parent → **node**) with outline.
349+
`colorByDepth` uses a pastel palette keyed to hierarchy depth.
348350

349351
Props: `data` (TNode, required — single root node with children),
350352
`childrenAccessor` (string|fn, "children"), `valueAccessor` (string|fn, "value"),
351353
`nodeIdAccessor` (string|fn, "name"),
352354
`colorBy` (string|fn), `colorScheme` (string|string[], "category10"),
353355
`colorByDepth` (boolean, false),
354-
`showLabels` (boolean, true), `nodeLabel` (string|fn),
356+
`showLabels` (boolean, true), `labelMode` ("leaf"|"parent"|"all", "leaf"),
357+
`nodeLabel` (string|fn),
358+
`padding` (number, 4), `paddingTop` (number, 0 — auto 18 when labelMode="parent"),
355359
`title` (string), `width` (number, 600), `height` (number, 600),
356360
`enableHover` (boolean, true), `tooltip` (fn),
357361
`margin` (object), `className` (string), `frameProps` (object)
@@ -361,15 +365,18 @@ Props: `data` (TNode, required — single root node with children),
361365
```
362366

363367
#### CirclePack
364-
Nested circles representing hierarchical data.
368+
Nested circles representing hierarchical data. Leaf labels are centered; parent labels
369+
are top-center with white-outlined black text. Circles smaller than 15px radius hide labels.
370+
Hover shows ancestor breadcrumb path (grandparent → parent → **node**) with circle outline.
371+
`colorByDepth` uses a pastel palette keyed to hierarchy depth (same as Treemap).
365372

366373
Props: `data` (TNode, required — single root node with children),
367374
`childrenAccessor` (string|fn, "children"), `valueAccessor` (string|fn, "value"),
368375
`nodeIdAccessor` (string|fn, "name"),
369376
`colorBy` (string|fn), `colorScheme` (string|string[], "category10"),
370377
`colorByDepth` (boolean, false),
371378
`showLabels` (boolean, true), `nodeLabel` (string|fn),
372-
`circleOpacity` (number, 0.7),
379+
`circleOpacity` (number, 0.7), `padding` (number, 4),
373380
`title` (string), `width` (number, 600), `height` (number, 600),
374381
`enableHover` (boolean, true), `tooltip` (fn),
375382
`margin` (object), `className` (string), `frameProps` (object)

docs/src/IndexPages.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export function GuidesIndex() {
129129
/>
130130
<PageLink
131131
href="/guides/small-multiples"
132-
title="Small Multiples"
132+
title="Linked Charts"
133133
thumbnail={new URL("../public/assets/img/facet.png", import.meta.url)}
134134
/>
135135
<PageLink

docs/src/components/navData.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const navData = [
8686
{ title: "Accessibility", path: "/features/accessibility" },
8787
{ title: "Canvas Rendering", path: "/features/canvas-rendering" },
8888
{ title: "Sparklines", path: "/features/sparklines" },
89-
{ title: "Small Multiples", path: "/features/small-multiples" },
89+
{ title: "Linked Charts", path: "/features/small-multiples" },
9090
{ title: "Styling", path: "/features/styling" },
9191
{ title: "Legends", path: "/features/legends" }
9292
]

docs/src/guides/SmallMultiples.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default function() {
77
<MarkdownText
88
text={`
99
10-
Small multiples are now accomplished using \`LinkedCharts\`, which provides a modern producer-consumer coordination system for cross-highlighting, brushing-and-linking, and cross-filtering between charts. See the [Small Multiples & Coordinated Views](/features/small-multiples) page for details.
10+
Small multiples and coordinated views are now accomplished using \`LinkedCharts\`, which provides a modern producer-consumer coordination system for cross-highlighting, brushing-and-linking, and cross-filtering between charts. See the [Linked Charts](/features/small-multiples) page for details.
1111
1212
`}
1313
/>

docs/src/pages/charts/TreemapPage.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ const treemapProps = [
5656
{ name: "colorScheme", type: "string | array", required: false, default: '"category10"', description: "Color scheme for nodes or custom colors array." },
5757
{ name: "colorByDepth", type: "boolean", required: false, default: "false", description: "Color nodes by hierarchy depth level." },
5858
{ name: "showLabels", type: "boolean", required: false, default: "true", description: "Show labels on treemap cells." },
59+
{ name: "labelMode", type: '"leaf" | "parent" | "all"', required: false, default: '"leaf"', description: 'Which nodes to label. "leaf" labels only leaf nodes, "parent" labels depth-1 group headers, "all" labels everything.' },
5960
{ name: "nodeLabel", type: "string | function", required: false, default: "nodeIdAccessor", description: "Node label accessor." },
61+
{ name: "padding", type: "number", required: false, default: "4", description: "Padding between parent and child rectangles (pixels). Makes parent containers visible." },
62+
{ name: "paddingTop", type: "number", required: false, default: '0 (18 when labelMode="parent")', description: "Extra padding at the top of parent nodes. Provides space for parent label text." },
6063
{ name: "enableHover", type: "boolean", required: false, default: "true", description: "Enable hover annotations." },
6164
{ name: "tooltip", type: "object | function", required: false, default: null, description: "Tooltip configuration or render function." },
6265
{ name: "width", type: "number", required: false, default: "600", description: "Chart width in pixels." },
@@ -176,6 +179,31 @@ export default function TreemapPage() {
176179
hiddenProps={{}}
177180
/>
178181

182+
<h3 id="parent-labels">Parent Labels</h3>
183+
<p>
184+
Use <code>labelMode="parent"</code> to label only the top-level groups.
185+
This automatically adds <code>paddingTop</code> for header space.
186+
</p>
187+
188+
<LiveExample
189+
frameProps={{
190+
data: sampleData,
191+
nodeIdAccessor: "name",
192+
valueAccessor: "value",
193+
colorBy: "name",
194+
labelMode: "parent",
195+
padding: 3,
196+
}}
197+
type={Treemap}
198+
overrideProps={{
199+
data: "hierarchyData",
200+
colorBy: '"name"',
201+
labelMode: '"parent"',
202+
padding: "3",
203+
}}
204+
hiddenProps={{}}
205+
/>
206+
179207
{/* ----------------------------------------------------------------- */}
180208
{/* Props */}
181209
{/* ----------------------------------------------------------------- */}

docs/src/pages/features/LegendsPage.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,8 +591,8 @@ function InteractiveChart({ data }) {
591591
patterns, and visual encodings
592592
</li>
593593
<li>
594-
<Link to="/features/small-multiples">Small Multiples</Link> — shared
595-
legends across multiple coordinated frames
594+
<Link to="/features/small-multiples">Linked Charts</Link> — shared
595+
legends across multiple coordinated charts
596596
</li>
597597
</ul>
598598
</PageLayout>

docs/src/pages/features/SmallMultiplesPage.js

Lines changed: 114 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,29 @@ import PropTable from "../../components/PropTable"
1515
import { Link } from "react-router-dom"
1616

1717
// ---------------------------------------------------------------------------
18-
// Sample data for LinkedCharts
18+
// Sample data for LinkedCharts live example
1919
// ---------------------------------------------------------------------------
2020

21-
const linkedData = [
22-
{ x: 10, y: 45, region: "North", income: 52 },
23-
{ x: 20, y: 55, region: "North", income: 61 },
24-
{ x: 30, y: 65, region: "South", income: 48 },
25-
{ x: 40, y: 35, region: "South", income: 39 },
26-
{ x: 50, y: 72, region: "East", income: 75 },
27-
{ x: 60, y: 62, region: "East", income: 68 },
28-
{ x: 70, y: 48, region: "West", income: 54 },
29-
{ x: 80, y: 58, region: "West", income: 62 },
21+
const salesData = [
22+
{ month: "Jan", revenue: 42, region: "North", spend: 18 },
23+
{ month: "Feb", revenue: 51, region: "North", spend: 22 },
24+
{ month: "Mar", revenue: 65, region: "South", spend: 28 },
25+
{ month: "Apr", revenue: 38, region: "South", spend: 15 },
26+
{ month: "May", revenue: 72, region: "East", spend: 35 },
27+
{ month: "Jun", revenue: 60, region: "East", spend: 30 },
28+
{ month: "Jul", revenue: 48, region: "West", spend: 20 },
29+
{ month: "Aug", revenue: 55, region: "West", spend: 24 },
30+
{ month: "Sep", revenue: 67, region: "North", spend: 31 },
31+
{ month: "Oct", revenue: 58, region: "South", spend: 26 },
32+
{ month: "Nov", revenue: 74, region: "East", spend: 38 },
33+
{ month: "Dec", revenue: 63, region: "West", spend: 29 },
3034
]
3135

32-
const barAgg = ["North", "South", "East", "West"].map((region) => ({
33-
category: region,
34-
total: linkedData.filter((d) => d.region === region).reduce((s, d) => s + d.income, 0),
36+
const regionTotals = ["North", "South", "East", "West"].map((region) => ({
3537
region,
38+
total: salesData
39+
.filter((d) => d.region === region)
40+
.reduce((s, d) => s + d.revenue, 0),
3641
}))
3742

3843
// ---------------------------------------------------------------------------
@@ -132,53 +137,91 @@ const splomProps = [
132137
export default function SmallMultiplesPage() {
133138
return (
134139
<PageLayout
135-
title="Small Multiples & Coordinated Views"
140+
title="Linked Charts"
136141
breadcrumbs={[
137142
{ label: "Features", path: "/features" },
138-
{ label: "Small Multiples", path: "/features/small-multiples" },
143+
{ label: "Linked Charts", path: "/features/small-multiples" },
139144
]}
140145
prevPage={{ title: "Sparklines", path: "/features/sparklines" }}
141146
nextPage={{ title: "Styling", path: "/features/styling" }}
142147
>
143148
<p>
144-
Semiotic provides <strong>LinkedCharts</strong> for modern
145-
producer-consumer coordination between charts, enabling
146-
cross-highlighting, brushing-and-linking, and cross-filtering.
149+
<strong>LinkedCharts</strong> is a React Context provider that enables
150+
cross-highlighting, brushing-and-linking, and cross-filtering between
151+
any Semiotic chart components at any depth in the tree. Charts opt in
152+
via the <code>selection</code>, <code>linkedHover</code>, and{" "}
153+
<code>linkedBrush</code> props.
147154
</p>
148155

149156
{/* ----------------------------------------------------------------- */}
150-
{/* LinkedCharts */}
157+
{/* Live example */}
151158
{/* ----------------------------------------------------------------- */}
152-
<h2 id="linked-charts">LinkedCharts (Recommended)</h2>
159+
<h2 id="example">Example</h2>
153160

154161
<p>
155-
<code>LinkedCharts</code> uses React Context to enable cross-highlighting,
156-
brushing-and-linking, and cross-filtering between any chart components
157-
at any depth in the tree. Charts opt in via the <code>selection</code>,{" "}
158-
<code>linkedHover</code>, and <code>linkedBrush</code> props.
162+
Hover over a point in the scatterplot to highlight the matching region
163+
in the bar chart, and vice versa:
159164
</p>
160165

161-
<h3 id="cross-highlighting">Cross-Highlighting</h3>
162-
<p>
163-
Hover over a point in one chart to highlight matching data in all
164-
linked charts:
165-
</p>
166+
<div style={{ marginBottom: 32 }}>
167+
<LinkedCharts>
168+
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
169+
<Scatterplot
170+
data={salesData}
171+
xAccessor="revenue"
172+
yAccessor="spend"
173+
colorBy="region"
174+
pointRadius={6}
175+
width={360}
176+
height={300}
177+
xLabel="Revenue"
178+
yLabel="Spend"
179+
linkedHover={{ name: "hl", fields: ["region"] }}
180+
selection={{ name: "hl" }}
181+
/>
182+
<BarChart
183+
data={regionTotals}
184+
categoryAccessor="region"
185+
valueAccessor="total"
186+
colorBy="region"
187+
width={320}
188+
height={300}
189+
valueLabel="Total Revenue"
190+
linkedHover={{ name: "hl", fields: ["region"] }}
191+
selection={{ name: "hl" }}
192+
/>
193+
</div>
194+
</LinkedCharts>
195+
</div>
166196

167197
<CodeBlock
168198
code={`import { LinkedCharts, Scatterplot, BarChart } from "semiotic"
169199
200+
const salesData = [
201+
{ month: "Jan", revenue: 42, region: "North", spend: 18 },
202+
{ month: "Feb", revenue: 51, region: "North", spend: 22 },
203+
// ...
204+
]
205+
206+
const regionTotals = ["North", "South", "East", "West"].map(region => ({
207+
region,
208+
total: salesData
209+
.filter(d => d.region === region)
210+
.reduce((s, d) => s + d.revenue, 0),
211+
}))
212+
170213
<LinkedCharts>
171214
<Scatterplot
172-
data={data}
173-
xAccessor="x"
174-
yAccessor="y"
215+
data={salesData}
216+
xAccessor="revenue"
217+
yAccessor="spend"
175218
colorBy="region"
176219
linkedHover={{ name: "hl", fields: ["region"] }}
177220
selection={{ name: "hl" }}
178221
/>
179222
<BarChart
180-
data={barData}
181-
categoryAccessor="category"
223+
data={regionTotals}
224+
categoryAccessor="region"
182225
valueAccessor="total"
183226
colorBy="region"
184227
linkedHover={{ name: "hl", fields: ["region"] }}
@@ -188,6 +231,40 @@ export default function SmallMultiplesPage() {
188231
language="jsx"
189232
/>
190233

234+
{/* ----------------------------------------------------------------- */}
235+
{/* How it works */}
236+
{/* ----------------------------------------------------------------- */}
237+
<h2 id="how-it-works">How It Works</h2>
238+
239+
<p>
240+
<code>LinkedCharts</code> wraps your charts in a shared selection
241+
store. Each chart can <strong>produce</strong> selections (via{" "}
242+
<code>linkedHover</code> or <code>linkedBrush</code>) and{" "}
243+
<strong>consume</strong> them (via <code>selection</code>). When a
244+
selection is active, non-matching elements are dimmed.
245+
</p>
246+
247+
<h3 id="selection-props">Selection Props on Charts</h3>
248+
<p>
249+
All HOC chart components accept these coordination props when used
250+
inside <code>LinkedCharts</code>:
251+
</p>
252+
253+
<CodeBlock
254+
code={`// selection — consume a named selection (dims unmatched elements)
255+
selection={{ name: "mySelection", unselectedOpacity: 0.2 }}
256+
257+
// linkedHover — produce hover-based selections
258+
linkedHover={{ name: "hl", fields: ["category"] }}
259+
linkedHover={true} // shorthand: name="hover", auto-detect fields
260+
linkedHover="myHoverName" // shorthand: custom name, auto-detect fields
261+
262+
// linkedBrush — produce brush-based selections (Scatterplot, BubbleChart only)
263+
linkedBrush={{ name: "brush", xField: "x", yField: "y" }}
264+
linkedBrush="selectionName" // shorthand`}
265+
language="jsx"
266+
/>
267+
191268
<h3 id="brush-and-link">Brush-and-Link</h3>
192269
<p>
193270
Brush a region in one chart to filter data in another. Use the{" "}
@@ -214,7 +291,7 @@ function FilteredDetail({ data }) {
214291
language="jsx"
215292
/>
216293

217-
<h3 id="cross-filtering">Cross-Filtering Dashboard</h3>
294+
<h3 id="cross-filtering">Cross-Filtering</h3>
218295
<p>
219296
With <code>resolution: "crossfilter"</code>, each chart's own brush is
220297
excluded from its filter — the standard SPLOM interaction model:
@@ -245,31 +322,6 @@ function FilteredDetail({ data }) {
245322
props={linkedChartsProps}
246323
/>
247324

248-
{/* ----------------------------------------------------------------- */}
249-
{/* Selection Props */}
250-
{/* ----------------------------------------------------------------- */}
251-
<h3 id="selection-props">Selection Props on Charts</h3>
252-
<p>
253-
All HOC chart components (Scatterplot, BarChart, LineChart, etc.)
254-
accept these coordination props when used inside{" "}
255-
<code>LinkedCharts</code>:
256-
</p>
257-
258-
<CodeBlock
259-
code={`// selection — consume a named selection (dims unmatched elements)
260-
selection={{ name: "mySelection", unselectedOpacity: 0.2 }}
261-
262-
// linkedHover — produce hover-based selections
263-
linkedHover={{ name: "hl", fields: ["category"] }}
264-
linkedHover={true} // shorthand: name="hover", auto-detect fields
265-
linkedHover="myHoverName" // shorthand: custom name, auto-detect fields
266-
267-
// linkedBrush — produce brush-based selections (Scatterplot, BubbleChart only)
268-
linkedBrush={{ name: "brush", xField: "x", yField: "y" }}
269-
linkedBrush="selectionName" // shorthand`}
270-
language="jsx"
271-
/>
272-
273325
{/* ----------------------------------------------------------------- */}
274326
{/* ScatterplotMatrix */}
275327
{/* ----------------------------------------------------------------- */}
@@ -279,7 +331,7 @@ linkedBrush="selectionName" // shorthand`}
279331
The <code>ScatterplotMatrix</code> (SPLOM) renders an N x N grid of
280332
scatterplots with built-in cross-filter brushing. Diagonal cells
281333
show histograms. Brushing one cell highlights matching points in
282-
all other cells.
334+
all other cells. It uses <code>LinkedCharts</code> internally.
283335
</p>
284336

285337
<div style={{ marginBottom: 32 }}>
@@ -383,7 +435,7 @@ const filtered = useFilteredData(data, "mySelection")`}
383435
</li>
384436
<li>
385437
<Link to="/features/legends">Legends</Link> — adding legends to your
386-
small multiple layouts
438+
coordinated chart layouts
387439
</li>
388440
</ul>
389441
</PageLayout>

0 commit comments

Comments
 (0)