Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
d7604d3
feat(quill-charts): add SankeyChart and use it for MCP cluster journeys
pauldambra Sep 22, 2026
f05b305
fix(quill-charts): sankey hit detection and all-zero flow handling
pauldambra Sep 22, 2026
5068202
test(quill-charts): make the sankey zero-node hit test catch the regr…
pauldambra Sep 22, 2026
9b9f747
refactor(quill-charts): share the sankey value formatter
pauldambra Sep 22, 2026
f2666eb
fix(quill-charts): dim inactive sankey links against their resting color
pauldambra Sep 22, 2026
51f28d2
fix(mcp_analytics): fix journey sankey sizing and tooltip shares
pauldambra Sep 22, 2026
daaa2cf
fix(mcp_analytics): trim journey tooltip comment to one line
pauldambra Sep 22, 2026
0a26879
fix(quill-charts): align sankey nodes to the right by distance from t…
pauldambra Sep 22, 2026
36b9ea3
feat(quill-charts): sankey tooltip placement and touch taps
pauldambra Sep 23, 2026
e32c1dc
fix(quill-charts): fire sankey clicks when tooltips are disabled
pauldambra Sep 26, 2026
48bcbc6
refactor(mcp_analytics): extract journey tooltip
pauldambra Sep 26, 2026
f08e6ba
fix(quill-charts): clear stale Sankey hover and separate journey nodes
pauldambra Sep 27, 2026
8ccbe48
perf(quill-charts): skip distant sankey hit checks
pauldambra Sep 27, 2026
6402cbb
fix(quill-charts): harden sankey layout inputs
pauldambra Sep 28, 2026
181c149
fix(quill-charts): reset sankey errors on data changes
pauldambra Sep 28, 2026
0319659
fix(quill-charts): match sankey hit testing to ribbons
pauldambra Sep 28, 2026
93e271e
fix(quill-charts): match the missing sankey node error in its test
pauldambra Sep 29, 2026
9ccc49b
fix(quill-charts): brace the duplicate sankey node id guard
pauldambra Oct 6, 2026
da584b6
fix(quill-charts): sample sankey ribbons densely enough to hit thin ones
pauldambra Oct 6, 2026
10c4261
fix(quill-charts): address sankey review feedback
pauldambra Oct 6, 2026
0def4f7
fix(quill-charts): address second sankey review round
pauldambra Oct 6, 2026
ac2221f
fix(quill-charts): address sankey triage round
pauldambra Oct 6, 2026
f2f95a2
refactor(quill-charts): apply simplify pass
pauldambra Oct 6, 2026
ef9fa52
fix(quill-charts): cap sankey node width by column count, not node count
pauldambra Oct 6, 2026
8aee48a
fix(quill-charts): address sankey review round
pauldambra Oct 6, 2026
6415423
fix(quill-charts): clear sankey hover on interactive overlays
pauldambra Oct 6, 2026
5e5acb3
refactor: apply simplify pass
pauldambra Oct 6, 2026
1954930
chore(quill-charts): cover transparent sankey nodes in the hover pass
pauldambra Oct 6, 2026
0c628a0
refactor(quill-charts): share tap tracking between chart interaction …
pauldambra Oct 6, 2026
68d7676
fix(quill-charts): skip zero-valued sankey links, fall back on an emp…
pauldambra Oct 7, 2026
84ee30f
chore(quill-charts): cover zero-valued ribbons in the sankey hover pass
pauldambra Oct 7, 2026
a325de2
chore(visual): update storybook baselines
posthog[bot] Oct 7, 2026
3b95dc7
feat(paths): render user paths with the quill SankeyChart behind a flag
pauldambra Sep 22, 2026
591b64b
fix(quill-charts): reset sankey hover dedup key on layout change
pauldambra Sep 23, 2026
d18ea89
refactor(paths): memoize chart width and dropoff totals
pauldambra Sep 23, 2026
d0441ea
fix(quill-charts): keep sankey hover reports and overlay in step with…
pauldambra Oct 6, 2026
59fd97c
fix(quill-charts): keep emphasized sankey links at least as opaque as…
pauldambra Oct 6, 2026
6290e29
fix(paths): lazy-load the sankey paths chart and address review
pauldambra Oct 6, 2026
2e62824
fix(quill-charts): call emphasisForHit in the sankey draw test
pauldambra Oct 6, 2026
ba31e1f
fix(paths): mark the node card popover as a chart overlay
pauldambra Oct 6, 2026
698a1cf
perf(paths): only check nearby cards when placing a hover-only card
pauldambra Oct 6, 2026
a24bb44
fix(quill-charts): restore the useRef import and emphasisForHit in sa…
pauldambra Oct 6, 2026
39f6d32
fix(quill-charts): stop emphasized sankey ribbons compounding past th…
pauldambra Oct 7, 2026
0ea40e8
fix(paths): stop drop-off blocks hovering the ribbons beneath them
pauldambra Oct 7, 2026
832f757
refactor(quill-charts): drop the dead overlay check in sankey hit-tes…
pauldambra Oct 7, 2026
7c2ce0f
chore(visual): update storybook baselines
posthog[bot] Oct 7, 2026
ddd9708
feat(mcp): render paths query results as a Sankey in the MCP UI app
pauldambra Sep 22, 2026
278208f
fix(quill-charts): guard sankey nodeColumn pins against NaN and docum…
pauldambra Sep 23, 2026
790f0d4
fix(quill-charts): guard sankey collision resolution against empty pi…
pauldambra Sep 23, 2026
975a01e
fix(mcp): keep paths labels distinct, cap the drawn edges, and list t…
pauldambra Sep 23, 2026
976ee8e
fix(mcp): share paths transforms with the insight and address review
pauldambra Oct 6, 2026
4d3ee72
fix(mcp): use the default sankey tooltip in the paths app
pauldambra Oct 6, 2026
7dc99fb
fix(quill-charts): keep sankey labels across the last gap from overla…
pauldambra Oct 6, 2026
a7f56a4
fix(mcp): bound paths steps to the chart and count path starts honestly
pauldambra Oct 6, 2026
4f1be5f
fix(quill-charts): count pinned columns when capping sankey node width
pauldambra Oct 6, 2026
cb03a25
fix(mcp): cap sankey scroll width and node width at the laid-out colu…
pauldambra Oct 6, 2026
9816cf2
fix(quill-charts): keep sankey headers on the engine's columns and ev…
pauldambra Oct 6, 2026
a7ce1e5
refactor(product-analytics): count path origins with the shared helper
pauldambra Oct 6, 2026
1df0fb0
fix(mcp): count paths summary steps on the full result
pauldambra Oct 6, 2026
4297e71
fix(quill-charts): show sankey names in full on hover and in narrow c…
pauldambra Oct 7, 2026
5a6e9b4
fix(quill-charts): size sankey label room from the last column and ke…
pauldambra Oct 7, 2026
8f332c8
fix(quill-charts): reject bad sankey pins before the chart has a size
pauldambra Oct 7, 2026
dac8040
fix(quill-charts): keep sankey header room when the top margin is ove…
pauldambra Oct 7, 2026
6733477
chore(mcp-analytics): snapshot the cluster journey chart in storybook
pauldambra Oct 7, 2026
ef8f744
fix(quill-charts): keep a sankey plot when outside labels outgrow nar…
pauldambra Oct 7, 2026
d7788d9
fix(quill-charts): report the sankey node area as the plot to chart o…
pauldambra Oct 7, 2026
6369f79
fix(mcp): pin path steps only when they fit the visualizer's columns
pauldambra Oct 8, 2026
aa0abc9
fix(quill-charts): keep pin types apart in the sankey error reset key
pauldambra Oct 8, 2026
b6c4efb
fix(mcp): hide sankey node values when the paths view is truncated
pauldambra Oct 8, 2026
77761bd
Merge remote-tracking branch 'origin/master' into pastori/104591-t179…
pauldambra Oct 9, 2026
7225a88
Merge origin/master into posthog/quill-charts-sankey
pauldambra Oct 9, 2026
1557e2e
Merge posthog/paths-sankey-chart into posthog/mcp-paths-sankey
pauldambra Oct 9, 2026
efe6587
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
pauldambra Oct 9, 2026
68ccfcf
test(mcp): cover the paths visualizer's truncation, step count and sc…
pauldambra Oct 9, 2026
1be81e4
chore(quill-charts): document the sankey accessor methods
pauldambra Oct 9, 2026
e93d4e7
fix(quill-charts): count nodes fed only by zero links as sankey sources
pauldambra Oct 9, 2026
7f9e385
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
pauldambra Oct 9, 2026
19f2112
Merge posthog/paths-sankey-chart into posthog/mcp-paths-sankey
pauldambra Oct 9, 2026
0e623e3
fix(quill-charts): keep sankey emphasis linear and alpha-safe
pauldambra Oct 9, 2026
6086604
fix(paths): report errors caught by the sankey chart boundary
pauldambra Oct 9, 2026
3e47b61
Merge posthog/paths-sankey-chart into posthog/mcp-paths-sankey
pauldambra Oct 9, 2026
e65cfa9
fix(quill-charts): keep the sankey hover when the theme rereads an eq…
pauldambra Oct 9, 2026
8013175
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
pauldambra Oct 9, 2026
ecd5011
Merge posthog/paths-sankey-chart into posthog/mcp-paths-sankey
pauldambra Oct 9, 2026
1787dc1
fix(mcp): keep long paths under their step headers and qualify path s…
pauldambra Oct 10, 2026
1326358
chore(visual): update storybook baselines
posthog[bot] Oct 10, 2026
6071cb0
chore(visual): update storybook baselines
posthog[bot] Oct 10, 2026
a8c9e2f
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
pauldambra Oct 10, 2026
4adc7f9
Merge posthog/paths-sankey-chart into posthog/mcp-paths-sankey
pauldambra Oct 10, 2026
32c9781
chore(mcp): move sankey pinning rules into the docs and drop the vite…
pauldambra Oct 10, 2026
93450e8
chore(visual): update storybook baselines
posthog[bot] Oct 10, 2026
5d858d5
Merge origin/master into posthog/mcp-paths-sankey
pauldambra Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions frontend/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ snapshots:
charts-sankeychart--left-aligned-with-values--light:
hash: v1.k794b7964.71b32f628496dd8801855184c3ea3d3f0b47312041f148137107b7a48632b21d.3AfY_JFLCbBv12AAbc_xFpoqjxTK4XIZoKQJD1nc-Gw
charts-sankeychart--narrow--dark:
hash: v1.k794b7964.c0a92bc5346237079d7aa76591ef95866a0027554600de68d00488ec576ab34e.rae6pJ76-lo4OAFw2I21f4RwAfpkvPMce_DdY9MKzwQ
hash: v1.k794b7964.b2f0193be7c51e6f55e7971cd382edaa2aa2791e504e030a0158279767764f03.A48fC6kABWYb5bH8hKPUi-5GYX2I8zYoeyKzNS__FXE
charts-sankeychart--narrow--light:
hash: v1.k794b7964.1f36bc77ab15bef2f717ba801cb66cb03cfe83d0deedffc0e69b0448302bb56c.1uAAAcF-jsEeS7JkUg0VbDHHQ1YZAdpwXiMG_8X9TRM
hash: v1.k794b7964.7b456eb24ee839014b47a23a7fc05b317e93e5d13d8fc2f88bbb61f47a94fb5e.w53BwKHfejMxdOlnCqXv_NxY4KDAzPjKliLhvMQ4LEc
charts-sankeychart--tool-call-journeys--dark:
hash: v1.k794b7964.e50f64b0a2556e240047944407ec800fb78ffc2ad3eca93219f235d64883327d.9_vvmCGYMQ1aMSvgEOizcAf1tRGe7r0-FwNnrD3cHMo
charts-sankeychart--tool-call-journeys--light:
Expand Down Expand Up @@ -6306,6 +6306,12 @@ snapshots:
hash: v1.k794b7964.f6b81da6dbf0a63ddb55f3fc72b96ac5f72716698f914d73edb124e8ea17b0d1.2FXYB8hKUiO_FXveQuHw5J4O8WTK5bzOeiwCE9MUT4Q
mcp-apps-lifecycle--stacked--light:
hash: v1.k794b7964.e1057e251338bfbf9c5d6931d0f1a826064ab0c29843b716cf517ed7e8ff7d4e.y37bYZBa7mXiNTF6Xi_o7FtpVTdWgQa-xkc2koc_YRE
mcp-apps-paths--empty--light:
hash: v1.k794b7964.77b9d91893c5eb208216101495bb726906a1ebd04d17d5dbe24d24f0f23db822.DiRsE3sG-mQA5IpcZUhKFbZbDZ7xlblqQ5AQWlcm1VA
mcp-apps-paths--single-transition--light:
hash: v1.k794b7964.1c39a0bd2e56c6ad3c840cb2f2c7ac82b7fede3695fb7e9c699cf2b8b5af46c1.C8mutFhNrCTIQqpcRgICk-DmD4T4ldKtT70DJJ6iGkI
mcp-apps-paths--store-journeys--light:
hash: v1.k794b7964.d1a80e7a42dbfba333e75311b06e72992c611130708899769647558c2f661887.2p7Ut-EFMO8QR2OcwN_evJC-e-NBfFbaU7PzlBOr-SE
mcp-apps-query-results--single-number--light:
hash: v1.k794b7964.b53e77f7c428fe5d86903e3bee082eb7c752f67fb3225c043229e4b713debe69.hhBuSmExHBpDioDG9Ij-4qfvVhG77IC5FNYierdlZq8
mcp-apps-query-results--table--light:
Expand Down Expand Up @@ -12456,6 +12462,10 @@ snapshots:
hash: v1.k794b7964.9549b853f80d754b9d6ccf17ab144d24218143200c90df7af687e184e7de5329.Er1_HM8avmrqmulj2hCLnkp98-HeuRvF5ed3j-_jS2A
scenes-app-mcp-analytics--intent-clustering--light:
hash: v1.k794b7964.82fd6a9108a34ff8808b23b9c1e9255dcbe4f38562bb9e99333877dd303c08b2.qjDLxTHFZT3vcTHmPeG35-orU88GSS277RR2DdXxBdw
scenes-app-mcp-analytics--intent-clustering-journey--dark:
hash: v1.k794b7964.dca69b2edf84d19bb67ce3e2cc1a216fe6a63e9a1a36d8edb233b1f8ca60fd47.DJ-_pBzuUihpltpNAimi3dx_FewI6FzyLNKdoMZAMH4
scenes-app-mcp-analytics--intent-clustering-journey--light:
hash: v1.k794b7964.ac0eec4da423308decb9fbbd3f220e099b0a0a54dec45784b68c3bea89e7ec4d.vT3XHi6hXU4CIW6_dGDW6ShwhxyN5i1G-w00_qLnx3k
scenes-app-mcp-analytics--sessions--dark:
hash: v1.k794b7964.b9da0424c439b93ca4cfee035f623db248bf270c2f35d67fb149913d51357021.MHRBg5VvUtimcxoRkdmXRQmmxfzUts7dkMN-IIoczzI
scenes-app-mcp-analytics--sessions--light:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { fireEvent, render, waitFor } from '@testing-library/react'
import type { ChartTheme } from '../../core/types'
import { getHogChartTooltip, renderHogChart } from '../../testing'
import { computeSankeyLayout } from './sankey-data'
import type { SankeyLinkInput, SankeyNodeInput } from './sankey-data'
import type { SankeyLinkInput, SankeyNodeDatum, SankeyNodeInput } from './sankey-data'
import { labelsSharingLastGap, outsideLabelWidth, sankeyLabelBoxes } from './sankey-labels'
import { SankeyChart } from './SankeyChart'
import { fitHeader } from './SankeyColumnLabels'

const THEME: ChartTheme = { colors: ['#1f77b4', '#ff7f0e', '#2ca02c'], backgroundColor: '#ffffff' }

Expand Down Expand Up @@ -50,6 +52,29 @@ describe('SankeyChart', () => {
expect(chart.canvas.getAttribute('aria-label')).toBe('Sankey chart with 3 nodes and 2 links')
})

it('drops the label of a thin node that would print over its larger neighbor', () => {
// Two thin nodes stacked in one column: their boxes are a pixel or two tall, so their label
// centers sit closer than a line of text. The larger flow keeps its label.
const { chart } = renderHogChart(
<SankeyChart
nodes={[
{ id: 'start', label: 'Start' },
{ id: 'a', label: 'Big' },
{ id: 'b', label: 'Thin' },
{ id: 'c', label: 'Thinner' },
]}
links={[
{ source: 'start', target: 'a', value: 400 },
{ source: 'start', target: 'b', value: 2 },
{ source: 'start', target: 'c', value: 1 },
]}
theme={THEME}
config={{ nodePadding: 0 }}
/>
)
expect(chart.sankeyNodeLabels()).toEqual(['Start', 'Big', 'Thin'])
})

it('shows the hovered node in the tooltip, reports it once, and fires onNodeClick for it', async () => {
const onNodeClick = jest.fn()
const onHoverChange = jest.fn()
Expand Down Expand Up @@ -210,7 +235,13 @@ describe('SankeyChart', () => {
broken: { links: [LINKS[0], { source: 'a', target: 'missing', value: 12 }] },
},
{ name: 'the node padding', broken: { config: { nodePadding: -1 } } },
])('recovers from an invalid graph when only $name is corrected', ({ broken }) => {
{ name: 'a NaN pin', broken: { nodes: [{ ...NODES[0], column: NaN }, ...NODES.slice(1)] } },
{
name: 'a numeric-string pin',
broken: { nodes: [{ ...NODES[0], column: '0' as unknown as number }, ...NODES.slice(1)] },
corrected: { nodes: [{ ...NODES[0], column: 0 }, ...NODES.slice(1)] },
},
])('recovers from an invalid graph when only $name is corrected', ({ broken, corrected }) => {
jest.spyOn(console, 'error').mockImplementation(() => {})
const onError = jest.fn()
const { container, rerender } = render(
Expand All @@ -219,7 +250,131 @@ describe('SankeyChart', () => {
expect(onError).toHaveBeenCalled()
expect(container.textContent).toContain('Something went wrong')

rerender(<SankeyChart nodes={NODES} links={LINKS} theme={THEME} onError={onError} />)
rerender(<SankeyChart nodes={NODES} links={LINKS} theme={THEME} onError={onError} {...corrected} />)
expect(container.textContent).not.toContain('Something went wrong')
})

it('finds labels that face each other across the gap the last two columns share', () => {
const node = (id: string, column: number, y0: number): SankeyNodeDatum => ({
id,
label: id,
color: '#000',
index: 0,
column,
value: 1,
x0: 0,
x1: 0,
y0,
y1: y0 + 4,
})
const first = node('first', 0, 100)
const penultimate = node('penultimate', 1, 100)
const last = node('last', 2, 102)
const lastFarAway = node('lastFarAway', 2, 200)
expect(labelsSharingLastGap(new Set([first, penultimate, last, lastFarAway]), 3)).toEqual(
new Set([penultimate, last])
)
})

it('shows the node tooltip when the pointer is over its label, not the ribbon under it', async () => {
const { chart } = renderHogChart(<SankeyChart nodes={NODES} links={LINKS} theme={THEME} />, {
nativeTooltip: true,
})
// Right of the Start node, inside its label and over the Start -> Tool A ribbon.
const start = nodeCenter('start')
await waitFor(() => {
fireEvent.mouseMove(chart.element, { clientX: start.clientX + 17, clientY: start.clientY })
expect(getHogChartTooltip()?.textContent).toContain('Start')
})
expect(getHogChartTooltip()?.textContent).not.toContain('→')
})

it('places last-column labels outside the nodes when asked, and truncates headers to their span', () => {
Comment thread
pauldambra marked this conversation as resolved.
const layout = computeSankeyLayout({
nodes: NODES,
links: LINKS,
plot: { plotLeft: 8, plotTop: 8, plotWidth: 600, plotHeight: 300 },
nodeWidth: 12,
nodePadding: 8,
nodeAlign: 'justify',
preserveNodeOrder: false,
colorForLabel: () => '#000',
resolveColor: (c) => c,
})
const options = { showValues: false, valueFormatter: String, outsideWidth: 120 }
const sideOfLast = (lastColumnLabels: 'inside' | 'outside'): string | undefined =>
sankeyLabelBoxes(layout, { ...options, lastColumnLabels }).find((box) => box.text === 'Completed')?.side
expect([sideOfLast('inside'), sideOfLast('outside')]).toEqual(['left', 'right'])

expect(fitHeader('Outcome', 1000)).toBe('Outcome')
expect(fitHeader('4th tool', 8)).toBe('4th…')
})

it('draws no label where a narrow chart leaves it no room', () => {
const layout = computeSankeyLayout({
nodes: NODES,
links: LINKS,
plot: { plotLeft: 8, plotTop: 8, plotWidth: 40, plotHeight: 300 },
nodeWidth: 12,
nodePadding: 8,
nodeAlign: 'justify',
preserveNodeOrder: false,
colorForLabel: () => '#000',
resolveColor: (c) => c,
})
const boxes = sankeyLabelBoxes(layout, {
showValues: false,
valueFormatter: String,
lastColumnLabels: 'inside',
outsideWidth: 0,
})
expect(boxes).toEqual([])
})

it.each([
{ nodeAlign: 'left' as const, sizedBy: 'Completed' },
{ nodeAlign: 'justify' as const, sizedBy: 'Left after the first tool' },
])('sizes the outside margin by the sinks in the last column under $nodeAlign', ({ nodeAlign, sizedBy }) => {
const nodes: SankeyNodeInput[] = [...NODES, { id: 'early', label: 'Left after the first tool' }]
const links: SankeyLinkInput[] = [...LINKS, { source: 'start', target: 'early', value: 3 }]
const onlySink = outsideLabelWidth([{ id: 'only', label: sizedBy }], [], nodeAlign, false, String)
expect(outsideLabelWidth(nodes, links, nodeAlign, false, String)).toBe(onlySink)
})

it('keeps nodes and labels when the outside label room is wider than half the plot', () => {
const nodes: SankeyNodeInput[] = [
{ id: 'start', label: 'Start' },
{ id: 'done', label: 'Completed after a very long outcome name that needs the full margin' },
]
// An 800px wrapper with a 700px left margin leaves a 92px plot, under the 160px label cap.
const { chart } = renderHogChart(
<SankeyChart
nodes={nodes}
links={[{ source: 'start', target: 'done', value: 5 }]}
theme={THEME}
config={{ lastColumnLabels: 'outside', margins: { left: 700 } }}
/>
)
const labels = Array.from(
chart.element.querySelectorAll<HTMLElement>('[data-attr="hog-chart-sankey-node-label"]')
).map((label) => label.textContent)
expect(labels).toEqual([expect.stringMatching(/^Start/), expect.stringMatching(/^Comp.*…/)])
})

it('keeps room for column headers when a consumer overrides the top margin', () => {
const { chart } = renderHogChart(
<SankeyChart
nodes={NODES}
links={LINKS}
theme={THEME}
config={{ columnLabels: ['Init', 'Tool', 'Outcome'], margins: { top: 0 } }}
/>
)
// Start fills the plot height, so its label sits at the plot's vertical center: 18px of
// header room above a 400px wrapper with an 8px bottom margin.
const start = Array.from(
chart.element.querySelectorAll<HTMLElement>('[data-attr="hog-chart-sankey-node-label"]')
).find((label) => label.textContent?.startsWith('Start'))
expect(start?.style.top).toBe(`${18 + (400 - 18 - 8) / 2}px`)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { SankeyLayoutContext } from './sankey-context'
import type { SankeyLayoutContextValue } from './sankey-context'
import { computeSankeyLayout, defaultValueFormatter, hoverIndexToHit } from './sankey-data'
import type { SankeyChartLayout, SankeyLinkInput, SankeyNodeInput } from './sankey-data'
import { outsideLabelWidth, sankeyLabelBoxes } from './sankey-labels'
import type { SankeyLabelBox } from './sankey-labels'
import { SankeyColumnLabels } from './SankeyColumnLabels'
import { SankeyNodeLabels } from './SankeyNodeLabels'
import type { SankeyChartProps, SankeyTooltipContext } from './types'
Expand All @@ -41,8 +43,10 @@ function graphKey(
config: SankeyChartProps['config']
): string {
// Structured serialization: ids are free-form strings, so a delimiter inside one must not collide.
// JSON writes both NaN and a missing pin as null, so pins go in as strings to keep them apart.
// The type goes in too, so a rejected '1' and a corrected 1 do not share a key.
return JSON.stringify([
nodes.map((node) => node.id),
nodes.map((node) => [node.id, typeof node.column, String(node.column)]),
links.map(({ source, target, value }) => [source, target, value]),
[config?.nodeWidth, config?.nodePadding, config?.nodeAlign, config?.preserveNodeOrder],
])
Expand Down Expand Up @@ -83,6 +87,7 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
preserveNodeOrder = false,
columnLabels,
showNodeLabels = true,
lastColumnLabels = 'inside',
showNodeValues = false,
linkOpacity: configuredLinkOpacity = DEFAULT_LINK_OPACITY,
valueFormatter = defaultValueFormatter,
Expand All @@ -95,15 +100,38 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
const showTooltip = tooltipConfig?.enabled !== false
const hasColumnLabels = !!columnLabels && columnLabels.length > 0

const outsideWidth = useMemo(
() =>
showNodeLabels && lastColumnLabels === 'outside'
? outsideLabelWidth(nodes, links, nodeAlign, showNodeValues, valueFormatter)
: 0,
[showNodeLabels, lastColumnLabels, nodes, links, nodeAlign, showNodeValues, valueFormatter]
)

const margins = useMemo<ChartMargins>(() => {
const computed = hasColumnLabels
? { ...BASE_MARGINS, top: BASE_MARGINS.top + COLUMN_LABEL_HEIGHT }
: BASE_MARGINS
return marginsOverride ? applyMarginOverride(computed, marginsOverride) : computed
const applied = marginsOverride ? applyMarginOverride(BASE_MARGINS, marginsOverride) : BASE_MARGINS
// Column headers are sized for their room, so an override cannot take it away.
return { ...applied, top: applied.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0) }
}, [hasColumnLabels, marginsOverride])

const { canvasRef, overlayCanvasRef, wrapperRef, dimensions, ctx, overlayCtx } = useChartCanvas({ margins })

// `outside` labels take their room from the plot, capped at half of it, so a chart narrower
// than the labels still has nodes to draw and the labels truncate instead.
const outsideRoom = dimensions ? Math.min(outsideWidth, Math.floor(dimensions.plotWidth / 2)) : 0
const plot = useMemo(
() =>
dimensions
? {
plotLeft: dimensions.plotLeft,
plotTop: dimensions.plotTop,
plotWidth: dimensions.plotWidth - outsideRoom,
plotHeight: dimensions.plotHeight,
}
: { plotLeft: 0, plotTop: 0, plotWidth: 0, plotHeight: 0 },
[dimensions, outsideRoom]
)

// `useChartTheme` rereads the CSS variables on any class change on <html> or <body>, and returns
// an equal palette in a new array. Keying on the values keeps that from rebuilding the layout,
// because a new layout clears the hover and its tooltip.
Expand All @@ -130,15 +158,15 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
computeSankeyLayout<NodeMeta, LinkMeta>({
nodes,
links,
plot: dimensions ?? { plotLeft: 0, plotTop: 0, plotWidth: 0, plotHeight: 0 },
plot,
nodeWidth,
nodePadding,
nodeAlign,
preserveNodeOrder,
colorForLabel,
resolveColor: resolveCssColor,
}),
[nodes, links, dimensions, nodeWidth, nodePadding, nodeAlign, preserveNodeOrder, colorForLabel]
[nodes, links, plot, nodeWidth, nodePadding, nodeAlign, preserveNodeOrder, colorForLabel]
)

// A controlled highlight paints on the static layer, so a change to it is a full repaint
Expand All @@ -158,8 +186,22 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
[dimensions, layout, linkOpacity, hasEmphasis]
)

const labelBoxes = useMemo<SankeyLabelBox[]>(
() =>
showNodeLabels
? sankeyLabelBoxes(layout as SankeyChartLayout<unknown, unknown>, {
showValues: showNodeValues,
valueFormatter,
lastColumnLabels,
outsideWidth: outsideRoom,
})
: [],
[showNodeLabels, layout, showNodeValues, valueFormatter, lastColumnLabels, outsideRoom]
)

const { hoverIndex, hoverPosition, tooltipCtx, handlers } = useSankeyInteraction<NodeMeta, LinkMeta>({
layout,
labelBoxes,
canvasRef,
wrapperRef,
showTooltip,
Expand Down Expand Up @@ -231,7 +273,7 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
}
return {
scales,
dimensions,
dimensions: { ...dimensions, plotWidth: plot.plotWidth },
labels: [],
series: [],
theme,
Expand All @@ -240,7 +282,7 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
axis: { orientation: 'vertical', xTickFormatter: undefined, isPercent: false },
yGutters: [],
}
}, [scales, dimensions, theme, canvasBounds])
}, [scales, dimensions, plot.plotWidth, theme, canvasBounds])
const sankeyValue = useMemo<SankeyLayoutContextValue<NodeMeta, LinkMeta>>(
() => ({ layout, canvasBounds }),
[layout, canvasBounds]
Expand All @@ -266,14 +308,10 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
handlers={handlers}
showOverlay={!!dimensions}
>
{hasColumnLabels ? <SankeyColumnLabels labels={columnLabels} color={labelColor} /> : null}
{showNodeLabels ? (
<SankeyNodeLabels
color={labelColor}
showValues={showNodeValues}
valueFormatter={valueFormatter}
/>
{hasColumnLabels ? (
<SankeyColumnLabels labels={columnLabels} color={labelColor} trailingRoom={outsideRoom} />
) : null}
{showNodeLabels ? <SankeyNodeLabels boxes={labelBoxes} color={labelColor} /> : null}
{children}
{tooltipCtx && showTooltip ? (
<Tooltip
Expand Down
Loading
Loading