Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
efe6587
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
pauldambra Oct 9, 2026
7f9e385
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
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
8013175
Merge posthog/quill-charts-sankey into posthog/paths-sankey-chart
pauldambra Oct 9, 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
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
10 changes: 9 additions & 1 deletion frontend/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,14 @@ snapshots:
hash: v1.k794b7964.8e7770037014d788ef5b7299b6b2f834c2afcad14dd6c6033ddd8ac885faa1c6.buSflXKIcQC5dcm7fbQWsczrZY8TleOkvO86ugYW8yw
charts-heatmap--linear-color-scale--light:
hash: v1.k794b7964.c94c02704b7f9b4972f811336d019e15b77cff047551902ca74d65c1571ea6ff.PA6k_wyPYr-SNYm1-Mv8sa0DFCLb2-8QxOVNlDn4hE8
charts-sankeychart--controlled-highlight--dark:
hash: v1.k794b7964.eba0651fe51c58db0efb22be780f9c3312770910860cf3c1c0ebc899677af34d.y1waF2MLNvKZONNX977TQ_wQE4cB_YRyddAMgZrBdBk
charts-sankeychart--controlled-highlight--light:
hash: v1.k794b7964.037b47557a521539baf510394e37a3947590d6e4f37158823c58d5a0009ee614.ROQipQPqSPpPTPDgQMtDB1xrv3R05qoKQ6KijSSsHgE
charts-sankeychart--hovered-link--dark:
hash: v1.k794b7964.e50f64b0a2556e240047944407ec800fb78ffc2ad3eca93219f235d64883327d.X8189N1MHu1yKO4xcQgIglRR8DdqCLFhNfcqHuspBNE
charts-sankeychart--hovered-link--light:
hash: v1.k794b7964.e6a0d26ab605825cb5536b0d2ff19e606d1720fe7db242a0b72e710246fa01ad.iXgOgNfOVXu_y_Wg2MG9-4ugoULs_kgICgXVoRmaMh0
hash: v1.k794b7964.e13a6e93fd46df674c93764a5d35d4e880614ea11476c4aa73233dfdb26bc89d.HI4tX3ww4ZJGOZQVW95F4_VRUjg5a50dwjVm0DI3ngE
charts-sankeychart--left-aligned-with-values--dark:
hash: v1.k794b7964.1feb41fa4c6017593646eff1e33c11eb8ef9e224e5776a2bca43c97ff2390f9b.OQ83mEQIk1BTINe94sYI5ByoXnLHMvRo8W_9an60lrI
charts-sankeychart--left-aligned-with-values--light:
Expand Down Expand Up @@ -3916,6 +3920,10 @@ snapshots:
hash: v1.k794b7964.63b1b171e7a383ea613ae033e9455f932b4ecd23f2f2a9c8cea6ad172b7320c3.m4N1HRS9A1bcQK8FtoM5drv-4DLmsvWeXRVgReHzG0g
insights-paths--user-paths--light:
hash: v1.k794b7964.e0757ba6f44ade516787a8e68989216fcadd06f26a9f4bc4b9a91686a3173e4a.899TyMHHv2cx-QfVpz7xFZu72QNf_Ro_KOur0BZwq-M
insights-paths--user-paths-sankey-chart--dark:
hash: v1.k794b7964.08416361f74f936778d6bf316b3490c2b17889f8e4f845ab35eb54a2d184f9fb.4QIVsxjS5d-PXsbopqXzdBzYeMYiDUwW3oln8UuW-xk
insights-paths--user-paths-sankey-chart--light:
hash: v1.k794b7964.81f8b343ebbb9b039bf7907067bec981243ff77c7c0fac176652d6f25b131885.ilAVRrwxK7zxNHtEP8bwL_cT_XzXa4V9ndg-b1KjLbo
insights-regionmap--default--dark:
hash: v1.k794b7964.27f645d497cc1af88387932b1f580fff796596ea0cea520c6c3ce745a2625612.DbzWk3NIbbjDzeeJvLUlooVLALtBzLDZ02gl6S6mllk
insights-regionmap--default--light:
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export const FEATURE_FLAGS = {
PRODUCT_ANALYTICS_DASHBOARD_MODAL_SMART_DEFAULTS: 'product-analytics-dashboard-modal-smart-defaults', // owner: @sam #team-product-analytics
PRODUCT_ANALYTICS_HOME_TAB: 'product-analytics-home-tab', // owner: #team-product-analytics
PRODUCT_ANALYTICS_INSIGHT_HORIZONTAL_CONTROLS: 'insight-horizontal-controls', // owner: #team-product-analytics
PRODUCT_ANALYTICS_PATHS_SANKEY_CHART: 'paths-sankey-chart', // owner: @pauldambra #team-product-analytics, renders the paths insight with the quill SankeyChart instead of the d3 SVG
PRODUCT_ANALYTICS_PATHS_STRIP_QUERY_STRING: 'paths-strip-query-string', // owner: #team-product-analytics, gates the "Ignore URL query strings" switch on paths insights
PRODUCT_ANALYTICS_PATHS_V2: 'paths-v2', // owner: @thmsobrmlr #team-product-analytics
PRODUCT_ANALYTICS_QUARTER_YEAR_INTERVALS: 'product-analytics-quarter-year-intervals', // owner: @sampennington #team-product-analytics, gates quarter/year interval selection on insights
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,30 @@ export const LeftAlignedWithValues: Story = {
},
}

// The host decides what emphasis means: here every session that reached execute-sql at the
// second stage, upstream and downstream, while the chart's own hover dimming stays off.
const SQL_PATH_HIGHLIGHT = {
nodeIds: new Set(['start', '1:schema', '1:sql', '2:sql', 'completed', 'error']),
linkIndices: new Set([0, 1, 3, 5, 9, 10]),
}

export const ControlledHighlight: Story = {
render: function Render() {
const theme = useReactiveTheme()
return (
<Stage width={720} height={320}>
<SankeyChart
nodes={NODES}
links={LINKS}
theme={theme}
config={{ columnLabels: STAGES }}
highlight={SQL_PATH_HIGHLIGHT}
/>
</Stage>
)
},
}

export const Narrow: Story = {
render: function Render() {
const theme = useReactiveTheme()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,17 @@ describe('SankeyChart', () => {
expect(chart.canvas.getAttribute('aria-label')).toBe('Sankey chart with 3 nodes and 2 links')
})

it('shows the hovered node in the tooltip and fires onNodeClick for it', async () => {
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()
const { chart, rerender } = renderHogChart(
<SankeyChart nodes={NODES} links={LINKS} theme={THEME} onNodeClick={onNodeClick} />,
<SankeyChart
nodes={NODES}
links={LINKS}
theme={THEME}
onNodeClick={onNodeClick}
onHoverChange={onHoverChange}
/>,
{ nativeTooltip: true }
)
await waitFor(() => {
Expand All @@ -65,15 +72,28 @@ describe('SankeyChart', () => {
fireEvent.click(chart.element)
expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', value: 12 }))

// Repeated moves inside one node report a single hover; leaving reports null.
fireEvent.mouseMove(chart.element, nodeCenter('a'))
expect(onHoverChange.mock.calls).toEqual([[{ kind: 'node', node: expect.objectContaining({ id: 'a' }) }]])
fireEvent.mouseLeave(chart.element)
expect(onHoverChange).toHaveBeenLastCalledWith(null)

await waitFor(() => {
fireEvent.mouseMove(chart.element, nodeCenter('a'))
expect(getHogChartTooltip()?.textContent).toContain('Tool A')
})

rerender(
<SankeyChart
nodes={NODES}
links={LINKS}
theme={{ ...THEME, colors: [...THEME.colors] }}
onNodeClick={onNodeClick}
onHoverChange={onHoverChange}
/>
)
expect(getHogChartTooltip()?.textContent).toContain('Tool A')
expect(onHoverChange).not.toHaveBeenLastCalledWith(null)

onNodeClick.mockClear()
rerender(
Expand All @@ -82,32 +102,69 @@ describe('SankeyChart', () => {
links={LINKS}
theme={THEME}
onNodeClick={onNodeClick}
onHoverChange={onHoverChange}
config={{ nodeWidth: 24 }}
/>
)
await waitFor(() => expect(getHogChartTooltip()).toBeNull())
// The hovered node belonged to the old layout, so the host is told it is gone.
expect(onHoverChange).toHaveBeenLastCalledWith(null)
fireEvent.click(chart.element)
expect(onNodeClick).not.toHaveBeenCalled()
})

it('shows the tooltip on a first tap and fires onNodeClick on the second', async () => {
it('shows the tooltip on a first tap, fires onNodeClick on the second, and clears on an empty tap', async () => {
const onNodeClick = jest.fn()
const onHoverChange = jest.fn()
const { chart } = renderHogChart(
<SankeyChart nodes={NODES} links={LINKS} theme={THEME} onNodeClick={onNodeClick} />,
<SankeyChart
nodes={NODES}
links={LINKS}
theme={THEME}
onNodeClick={onNodeClick}
onHoverChange={onHoverChange}
/>,
{ nativeTooltip: true }
)
// A tap sends no mousemove first, so nothing is hovered when the click arrives. jsdom has
// no PointerEvent, so the pointer type rides on a MouseEvent React reads it from.
const tap = (): void => {
const tap = (at: { clientX: number; clientY: number }): void => {
const down = Object.assign(new MouseEvent('pointerdown', { bubbles: true }), { pointerType: 'touch' })
fireEvent(chart.element, down)
fireEvent.click(chart.element, nodeCenter('a'))
fireEvent.click(chart.element, at)
}
tap()
tap(nodeCenter('a'))
await waitFor(() => expect(getHogChartTooltip()?.textContent).toContain('Tool A'))
expect(onNodeClick).not.toHaveBeenCalled()
tap()
tap(nodeCenter('a'))
expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }))

// Inside the margin, outside every node and ribbon.
tap({ clientX: 2, clientY: 2 })
expect(onHoverChange).toHaveBeenLastCalledWith(null)
})

it('ignores a tap on an interactive overlay over a node', () => {
const onNodeClick = jest.fn()
const onHoverChange = jest.fn()
const { chart, getByRole } = renderHogChart(
<SankeyChart
nodes={NODES}
links={LINKS}
theme={THEME}
onNodeClick={onNodeClick}
onHoverChange={onHoverChange}
config={{ tooltip: { enabled: false } }}
>
<button data-hog-charts-interactive-overlay="">card</button>
</SankeyChart>
)
const down = Object.assign(new MouseEvent('pointerdown', { bubbles: true }), { pointerType: 'touch' })
fireEvent(chart.element, down)
fireEvent.click(getByRole('button'), nodeCenter('a'))

expect(onNodeClick).not.toHaveBeenCalled()
expect(onHoverChange).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'node' }))
})

it('fires the click on the first tap when the tooltip is disabled', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import type { ChartDrawArgs, ChartMargins, ChartScales } from '../../core/types'
import { defaultResolveValue } from '../../core/types'
import { Tooltip } from '../../overlays/Tooltip'
import { PieTooltip } from '../PieChart/PieTooltip'
import { drawSankey, drawSankeyHover, sankeyActiveFlow } from './draw-sankey'
import type { SankeyActiveFlow } from './draw-sankey'
import { drawSankey, drawSankeyHover, emphasisForHighlight, emphasisForHit } from './draw-sankey'
import type { SankeyEmphasis } from './draw-sankey'
import { SankeyLayoutContext } from './sankey-context'
import type { SankeyLayoutContextValue } from './sankey-context'
import { computeSankeyLayout, defaultValueFormatter, hoverIndexToHit } from './sankey-data'
Expand Down Expand Up @@ -70,6 +70,8 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
tooltip,
onNodeClick,
onLinkClick,
onHoverChange,
highlight,
className,
dataAttr,
children,
Expand Down Expand Up @@ -139,10 +141,21 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
[nodes, links, dimensions, nodeWidth, nodePadding, nodeAlign, preserveNodeOrder, colorForLabel]
)

// The shared draw loop keys repaints on `scales` identity, so wrap the layout in one.
// A controlled highlight paints on the static layer, so a change to it is a full repaint
// rather than a hover animation frame. The graph is small enough that this is cheap, and it
// keeps the hover overlay free to stay dark while the host owns emphasis.
const emphasis = useMemo(
() => (highlight ? emphasisForHighlight(layout as SankeyChartLayout<unknown, unknown>, highlight) : null),
[layout, highlight]
)
const hasEmphasis = emphasis !== null

// The shared draw loop keys repaints, including the hover overlay, on `scales` identity. The
// overlay must also repaint when `linkOpacity` changes or a controlled highlight starts or
// ends under a still cursor.
const scales = useMemo<ChartScales | null>(
() => (dimensions ? { ...NO_SCALES, _private: { __sankey: layout, linkOpacity } } : null),
[dimensions, layout, linkOpacity]
() => (dimensions ? { ...NO_SCALES, _private: { __sankey: layout, linkOpacity, hasEmphasis } } : null),
[dimensions, layout, linkOpacity, hasEmphasis]
)

const { hoverIndex, hoverPosition, tooltipCtx, handlers } = useSankeyInteraction<NodeMeta, LinkMeta>({
Expand All @@ -152,32 +165,39 @@ function SankeyChartInner<NodeMeta = unknown, LinkMeta = NodeMeta>({
showTooltip,
onNodeClick,
onLinkClick,
onHoverChange,
})

const drawStatic = useCallback(
({ ctx: drawCtx }: ChartDrawArgs) =>
drawSankey(drawCtx, layout as SankeyChartLayout<unknown, unknown>, { linkOpacity }),
[layout, linkOpacity]
({ ctx: drawCtx, theme: drawTheme }: ChartDrawArgs) =>
drawSankey(drawCtx, layout as SankeyChartLayout<unknown, unknown>, {
linkOpacity,
emphasis,
backgroundColor: drawTheme.backgroundColor,
}),
[layout, linkOpacity, emphasis]
)
// The hover fade repaints every frame; resolve the connected flow once per hovered item.
const activeFlowFor = useMemo(() => {
let cached: { index: number; flow: SankeyActiveFlow | null } | null = null
return (index: number): SankeyActiveFlow | null => {
const hoverEmphasisFor = useMemo(() => {
let cached: { index: number; emphasis: SankeyEmphasis | null } | null = null
return (index: number): SankeyEmphasis | null => {
if (cached?.index !== index) {
const untyped = layout as SankeyChartLayout<unknown, unknown>
cached = { index, flow: sankeyActiveFlow(untyped, hoverIndexToHit(untyped, index)) }
cached = { index, emphasis: emphasisForHit(untyped, hoverIndexToHit(untyped, index)) }
}
return cached.flow
return cached.emphasis
}
}, [layout])
const drawHover = useCallback(
({ ctx: drawCtx, hoverIndex: index, hoverProgress, theme: drawTheme }: ChartDrawArgs): boolean =>
drawSankeyHover(drawCtx, layout as SankeyChartLayout<unknown, unknown>, activeFlowFor(index), {
linkOpacity,
backgroundColor: drawTheme.backgroundColor,
progress: hoverProgress,
}),
[layout, linkOpacity, activeFlowFor]
emphasis
? false
: drawSankeyHover(drawCtx, layout as SankeyChartLayout<unknown, unknown>, hoverEmphasisFor(index), {
linkOpacity,
backgroundColor: drawTheme.backgroundColor,
progress: hoverProgress,
}),
[layout, linkOpacity, emphasis, hoverEmphasisFor]
)

useChartDraw({
Expand Down
Loading
Loading