From d7604d31b93b9004b97e98e80b80d4aebef67df1 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 22 Sep 2026 17:21:27 +0100 Subject: [PATCH 01/80] feat(quill-charts): add SankeyChart and use it for MCP cluster journeys Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- packages/quill/packages/charts/AGENTS.md | 3 +- .../SankeyChart/SankeyChart.stories.tsx | 101 ++++++ .../charts/SankeyChart/SankeyChart.test.tsx | 68 ++++ .../src/charts/SankeyChart/SankeyChart.tsx | 229 ++++++++++++++ .../charts/SankeyChart/SankeyColumnLabels.tsx | 54 ++++ .../charts/SankeyChart/SankeyNodeLabels.tsx | 52 +++ .../src/charts/SankeyChart/SankeyTooltip.tsx | 43 +++ .../src/charts/SankeyChart/draw-sankey.ts | 108 +++++++ .../src/charts/SankeyChart/sankey-context.ts | 25 ++ .../charts/SankeyChart/sankey-data.test.ts | 99 ++++++ .../src/charts/SankeyChart/sankey-data.ts | 296 ++++++++++++++++++ .../src/charts/SankeyChart/sankey-layout.ts | 50 +-- .../charts/src/charts/SankeyChart/types.ts | 62 ++++ .../SankeyChart/useSankeyInteraction.ts | 151 +++++++++ .../packages/charts/src/docs/chart-types.md | 21 ++ packages/quill/packages/charts/src/index.ts | 43 ++- .../packages/charts/src/testing/accessor.ts | 12 + .../clustering/ClusterJourneySankey.tsx | 238 +++----------- .../clustering/clusterJourneyGraph.test.ts | 32 ++ .../clustering/clusterJourneyGraph.ts | 73 +++++ .../frontend/insights/paths/renderPaths.ts | 11 +- 21 files changed, 1543 insertions(+), 228 deletions(-) create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/sankey-context.ts create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts rename frontend/src/vendor/d3/sankey.ts => packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts (94%) create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/types.ts create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts create mode 100644 products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts create mode 100644 products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts diff --git a/packages/quill/packages/charts/AGENTS.md b/packages/quill/packages/charts/AGENTS.md index 78ab064fdb48..5929be80ccc8 100644 --- a/packages/quill/packages/charts/AGENTS.md +++ b/packages/quill/packages/charts/AGENTS.md @@ -19,6 +19,7 @@ This file is the map. Prop-level semantics live in the JSDoc on the config and p | `ScatterChart` | Two continuous numeric axes — one marker per `{ x, y }` point; takes `points`, not `labels` | | `BoxPlot` | Distribution summaries — `{ min, p25, median, mean, p75, max }` per label | | `Heatmap` | 2D density grid (latency over time) — `xLabels` × `yLabels`, `cells[row][col]` | +| `SankeyChart` | Flow between stages (tool call journeys, user paths) — `nodes` + `links` keyed by id, no `series`; the graph must be acyclic | | `SlopeChart` | Change between two points — one line per series, `data: [start, end]` | | `Sparkline` | Tiny inline trend, no axes — gradient line or stacked bars; tooltip off by default | | `MetricCard` | Headline number + sparkline + change pill (dashboard stat tiles) | @@ -105,7 +106,7 @@ Import helpers from `@posthog/quill-charts/testing` (jsdom only): `getHogChart` | Doc | Covers | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | [src/README.md](./src/README.md) | Public surface, setup, theme, custom tooltip and overlay basics, sparkline | -| [docs/chart-types.md](./src/docs/chart-types.md) | Per-chart behavior: scatter, funnel, slope, pie, box plot, heatmap, sparkline, metric card | +| [docs/chart-types.md](./src/docs/chart-types.md) | Per-chart behavior: scatter, funnel, slope, pie, box plot, heatmap, sankey, sparkline, metric card | | [docs/axes.md](./src/docs/axes.md) | Defaults, grid and axis chrome, x-axis labels, y format, baseline and range, multi-axis, margins, blank-plot diagnosis | | [docs/bars.md](./src/docs/bars.md) | Layouts, per-bar overrides, `minBarSize`, `trackData`, hit-testing, trend lines, combo | | [docs/tooltips.md](./src/docs/tooltips.md) | `config.tooltip`, `DefaultTooltip` props, custom pieces, context fields, touch | diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx new file mode 100644 index 000000000000..389f893661a0 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx @@ -0,0 +1,101 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { playHoverAtFraction, Stage, useReactiveTheme } from '../../story-helpers' +import type { SankeyLinkInput, SankeyNodeInput } from './sankey-data' +import { SankeyChart } from './SankeyChart' + +// Agent sessions through an MCP server: which tool ran first, what came next, and how the +// session ended. The same tool appears in several stages, so each stage gets its own node id +// while nodes that share a label share a color. +const STAGES = ['Start', 'First tool', 'Second tool', 'Outcome'] + +const NODES: SankeyNodeInput[] = [ + { id: 'start', label: 'Sessions' }, + { id: '1:schema', label: 'read-data-schema' }, + { id: '1:sql', label: 'execute-sql' }, + { id: '1:trends', label: 'query-trends' }, + { id: '2:sql', label: 'execute-sql' }, + { id: '2:trends', label: 'query-trends' }, + { id: '2:insight', label: 'insight-create' }, + { id: '2:ended', label: 'Ended' }, + { id: 'completed', label: 'Completed', color: 'var(--data-color-3)' }, + { id: 'error', label: 'Error', color: 'var(--data-color-5)' }, +] + +const LINKS: SankeyLinkInput[] = [ + { source: 'start', target: '1:schema', value: 54 }, + { source: 'start', target: '1:sql', value: 31 }, + { source: 'start', target: '1:trends', value: 15 }, + { source: '1:schema', target: '2:sql', value: 38 }, + { source: '1:schema', target: '2:trends', value: 16 }, + { source: '1:sql', target: '2:sql', value: 22 }, + { source: '1:sql', target: '2:ended', value: 9 }, + { source: '1:trends', target: '2:insight', value: 11 }, + { source: '1:trends', target: '2:ended', value: 4 }, + { source: '2:sql', target: 'completed', value: 47 }, + { source: '2:sql', target: 'error', value: 13, color: 'var(--data-color-5)' }, + { source: '2:trends', target: 'completed', value: 16 }, + { source: '2:insight', target: 'completed', value: 11 }, + { source: '2:ended', target: 'error', value: 13, color: 'var(--data-color-5)' }, +] + +const meta: Meta = { + title: 'Charts/SankeyChart', + component: SankeyChart, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const ToolCallJourneys: Story = { + render: function Render() { + const theme = useReactiveTheme() + return ( + + + + ) + }, +} + +export const HoveredLink: Story = { + render: function Render() { + const theme = useReactiveTheme() + return ( + + + + ) + }, + play: async ({ canvasElement }) => { + await playHoverAtFraction(canvasElement, 0.2, 0.3) + }, +} + +export const LeftAlignedWithValues: Story = { + render: function Render() { + const theme = useReactiveTheme() + return ( + + + + ) + }, +} + +export const Narrow: Story = { + render: function Render() { + const theme = useReactiveTheme() + return ( + + + + ) + }, +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx new file mode 100644 index 000000000000..fb616af25903 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, 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 { SankeyChart } from './SankeyChart' + +const THEME: ChartTheme = { colors: ['#1f77b4', '#ff7f0e', '#2ca02c'], backgroundColor: '#ffffff' } + +const NODES: SankeyNodeInput[] = [ + { id: 'start', label: 'Start' }, + { id: 'a', label: 'Tool A' }, + { id: 'done', label: 'Completed' }, +] +const LINKS: SankeyLinkInput[] = [ + { source: 'start', target: 'a', value: 12 }, + { source: 'a', target: 'done', value: 12 }, +] + +// Mirrors the chart's own layout at the mocked 800x400 wrapper, so a cursor lands on a known node. +function nodeCenter(id: string): { clientX: number; clientY: number } { + const layout = computeSankeyLayout({ + nodes: NODES, + links: LINKS, + plot: { plotLeft: 8, plotTop: 8, plotWidth: 800 - 16, plotHeight: 400 - 16 }, + nodeWidth: 12, + nodePadding: 8, + nodeAlign: 'justify', + preserveNodeOrder: false, + colorForLabel: () => '#000', + resolveColor: (c) => c, + }) + const node = layout.nodes.find((n) => n.id === id)! + return { clientX: (node.x0 + node.x1) / 2, clientY: (node.y0 + node.y1) / 2 } +} + +describe('SankeyChart', () => { + it('renders node labels and column headers as DOM overlays', () => { + const { chart } = renderHogChart( + + ) + expect(chart.sankeyNodeLabels()).toEqual(['Start 12', 'Tool A 12', 'Completed 12']) + expect(chart.sankeyColumnLabels()).toEqual(['Init', '1st tool', 'Outcome']) + 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 () => { + const onNodeClick = jest.fn() + const { chart } = renderHogChart( + , + { nativeTooltip: true } + ) + await waitFor(() => { + fireEvent.mouseMove(chart.element, nodeCenter('a')) + expect(getHogChartTooltip()?.textContent).toContain('Tool A') + }) + // The share is of the total inflow, so the only path reads as 100%. + expect(getHogChartTooltip()?.textContent).toContain('100%') + fireEvent.click(chart.element) + expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', value: 12 })) + }) +}) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx new file mode 100644 index 000000000000..57562c7ca14d --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -0,0 +1,229 @@ +import React, { useCallback, useMemo } from 'react' + +import { ChartHoverContext, ChartLayoutContext } from '../../core/chart-context' +import type { ChartHoverContextValue, ChartLayoutContextValue } from '../../core/chart-context' +import { ChartShell, useCanvasBounds } from '../../core/chart-shell' +import { ChartErrorBoundary } from '../../core/ChartErrorBoundary' +import { resolveCssColor } from '../../core/color-utils' +import { useChartCanvas } from '../../core/hooks/useChartCanvas' +import { useChartDraw } from '../../core/hooks/useChartDraw' +import { applyMarginOverride } from '../../core/hooks/useChartMargins' +import type { ChartDrawArgs, ChartMargins, ChartScales } from '../../core/types' +import { defaultResolveValue } from '../../core/types' +import { Tooltip } from '../../overlays/Tooltip' +import { drawSankey, drawSankeyHover } from './draw-sankey' +import { SankeyLayoutContext } from './sankey-context' +import type { SankeyLayoutContextValue } from './sankey-context' +import { computeSankeyLayout, hoverIndexToHit } from './sankey-data' +import type { SankeyChartLayout } from './sankey-data' +import { SankeyColumnLabels } from './SankeyColumnLabels' +import { SankeyNodeLabels } from './SankeyNodeLabels' +import { SankeyTooltip } from './SankeyTooltip' +import type { SankeyChartProps, SankeyTooltipContext } from './types' +import { useSankeyInteraction } from './useSankeyInteraction' + +const DEFAULT_NODE_WIDTH = 12 +const DEFAULT_NODE_PADDING = 8 +const DEFAULT_LINK_OPACITY = 0.4 +const HOVER_ANIMATION_MS = 150 +const BASE_MARGINS: ChartMargins = { top: 8, right: 8, bottom: 8, left: 8 } +/** Room for the column header row above the plot. */ +const COLUMN_LABEL_HEIGHT = 18 +const DEFAULT_LABEL_COLOR = 'rgba(0, 0, 0, 0.7)' + +const NO_SCALES: ChartScales = { x: () => undefined, y: () => 0, yTicks: () => [] } + +function defaultValueFormatter(value: number): string { + return value.toLocaleString() +} + +export function SankeyChart({ + onError, + ...rest +}: SankeyChartProps): React.ReactElement { + return ( + + + + ) +} + +function SankeyChartInner({ + nodes, + links, + theme, + config, + tooltip, + onNodeClick, + onLinkClick, + className, + dataAttr, + children, +}: Omit, 'onError'>): React.ReactElement { + const { + nodeWidth = DEFAULT_NODE_WIDTH, + nodePadding = DEFAULT_NODE_PADDING, + nodeAlign = 'justify', + preserveNodeOrder = false, + columnLabels, + showNodeLabels = true, + showNodeValues = false, + linkOpacity = DEFAULT_LINK_OPACITY, + valueFormatter = defaultValueFormatter, + tooltip: tooltipConfig, + margins: marginsOverride, + } = config ?? {} + const showTooltip = tooltipConfig?.enabled !== false + const hasColumnLabels = !!columnLabels && columnLabels.length > 0 + + const margins = useMemo(() => { + const computed = hasColumnLabels + ? { ...BASE_MARGINS, top: BASE_MARGINS.top + COLUMN_LABEL_HEIGHT } + : BASE_MARGINS + return marginsOverride ? applyMarginOverride(computed, marginsOverride) : computed + }, [hasColumnLabels, marginsOverride]) + + const { canvasRef, overlayCanvasRef, wrapperRef, dimensions, ctx, overlayCtx } = useChartCanvas({ margins }) + + // Nodes that share a label share a palette slot, so the same tool in two columns keeps one hue. + const colorForLabel = useMemo(() => { + const slots = new Map() + for (const node of nodes) { + const label = node.label ?? node.id + if (!slots.has(label)) { + slots.set(label, theme.colors[slots.size % theme.colors.length]) + } + } + return (label: string): string => slots.get(label) ?? theme.colors[0] + }, [nodes, theme.colors]) + + const layout = useMemo>( + () => + computeSankeyLayout({ + nodes, + links, + plot: dimensions ?? { plotLeft: 0, plotTop: 0, plotWidth: 0, plotHeight: 0 }, + nodeWidth, + nodePadding, + nodeAlign, + preserveNodeOrder, + colorForLabel, + resolveColor: resolveCssColor, + }), + [nodes, links, dimensions, nodeWidth, nodePadding, nodeAlign, preserveNodeOrder, colorForLabel] + ) + + // The shared draw loop keys repaints on `scales` identity, so wrap the layout in one. + const scales = useMemo( + () => (dimensions ? { ...NO_SCALES, _private: { __sankey: layout } } : null), + [dimensions, layout] + ) + + const { hoverIndex, hoverPosition, tooltipCtx, handlers } = useSankeyInteraction({ + layout, + canvasRef, + wrapperRef, + showTooltip, + onNodeClick, + onLinkClick, + }) + + const drawStatic = useCallback( + ({ ctx: drawCtx }: ChartDrawArgs) => + drawSankey(drawCtx, layout as SankeyChartLayout, { linkOpacity }), + [layout, linkOpacity] + ) + const drawHover = useCallback( + ({ ctx: drawCtx, hoverIndex: index, hoverProgress, theme: drawTheme }: ChartDrawArgs): boolean => + drawSankeyHover(drawCtx, layout as SankeyChartLayout, hoverIndexToHit(layout, index), { + linkOpacity, + backgroundColor: drawTheme.backgroundColor, + progress: hoverProgress, + }), + [layout, linkOpacity] + ) + + useChartDraw({ + ctx, + overlayCtx, + dimensions, + scales, + series: [], + labels: [], + hoverIndex, + hoverPosition, + theme, + drawStatic, + drawHover, + hoverAnimationMs: HOVER_ANIMATION_MS, + }) + + const renderTooltip = useMemo( + () => + tooltip ?? + ((tooltipContext: SankeyTooltipContext): React.ReactNode => ( + + )), + [tooltip, valueFormatter] + ) + + const canvasBounds = useCanvasBounds(canvasRef) + const layoutValue = useMemo(() => { + if (!scales || !dimensions) { + return null + } + return { + scales, + dimensions, + labels: [], + series: [], + theme, + resolvePositionValue: defaultResolveValue, + canvasBounds, + axis: { orientation: 'vertical', xTickFormatter: undefined, isPercent: false }, + yGutters: [], + } + }, [scales, dimensions, theme, canvasBounds]) + const sankeyValue = useMemo>( + () => ({ layout, canvasBounds }), + [layout, canvasBounds] + ) + const hoverValue = useMemo(() => ({ hoverIndex }), [hoverIndex]) + + const ariaLabel = `Sankey chart with ${layout.nodes.length} nodes and ${layout.links.length} links` + const labelColor = theme.axisColor ?? DEFAULT_LABEL_COLOR + const clickable = hoverIndex >= 0 && !!(onNodeClick || onLinkClick) + + return ( + + + + + {hasColumnLabels ? : null} + {showNodeLabels ? ( + + ) : null} + {children} + {tooltipCtx && showTooltip ? ( + + ) : null} + + + + + ) +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx new file mode 100644 index 000000000000..95fb09f485ba --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +import { useSankeyLayout } from './sankey-context' + +const LABEL_STYLE_BASE: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'none', + top: 0, + fontSize: 10, + lineHeight: 1.2, + textTransform: 'uppercase', + letterSpacing: '0.04em', + whiteSpace: 'nowrap', +} + +export interface SankeyColumnLabelsProps { + labels: string[] + color: string +} + +/** Column headers over each node column. The first and last headers align to the outer node edge + * instead of centering, so they stay inside the chart. Extra labels beyond the column count are + * dropped, so a fixed stage list stays valid when the data has fewer stages. */ +export function SankeyColumnLabels({ labels, color }: SankeyColumnLabelsProps): React.ReactElement { + const { layout } = useSankeyLayout() + const lastColumn = layout.columnCount - 1 + return ( + <> + {layout.columnX.map((x, column) => { + const label = labels[column] + if (!label) { + return null + } + let placement: React.CSSProperties + if (column === 0) { + placement = { left: x } + } else if (column === lastColumn) { + placement = { right: `calc(100% - ${x + layout.nodeWidth}px)` } + } else { + placement = { left: x + layout.nodeWidth / 2, transform: 'translateX(-50%)' } + } + return ( +
+ {label} +
+ ) + })} + + ) +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx new file mode 100644 index 000000000000..c198ecab6830 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +import { FONT_FAMILY, truncateToWidth } from '../../utils/text-measure' +import { useSankeyLayout } from './sankey-context' + +const LABEL_FONT_SIZE = 11 +const LABEL_FONT = `${LABEL_FONT_SIZE}px ${FONT_FAMILY}` +const LABEL_GAP = 6 + +const LABEL_STYLE_BASE: React.CSSProperties = { + position: 'absolute', + pointerEvents: 'none', + fontSize: LABEL_FONT_SIZE, + lineHeight: 1.2, + whiteSpace: 'nowrap', + transform: 'translateY(-50%)', +} + +export interface SankeyNodeLabelsProps { + color: string + showValues: boolean + valueFormatter: (value: number) => string +} + +/** One label per node, to the right of it; the last column's labels sit to its left so they stay + * inside the plot. Labels truncate to the free space before the next column. */ +export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNodeLabelsProps): React.ReactElement { + const { layout } = useSankeyLayout() + const gap = layout.columnCount > 1 ? layout.columnX[1] - layout.columnX[0] - layout.nodeWidth : Infinity + const maxWidth = gap - LABEL_GAP * 2 + + return ( + <> + {layout.nodes.map((node) => { + const text = showValues ? `${node.label} ${valueFormatter(node.value)}` : node.label + const last = node.column === layout.columnCount - 1 && layout.columnCount > 1 + const shown = truncateToWidth(text, isFinite(maxWidth) ? maxWidth : Infinity, LABEL_FONT) + const style: React.CSSProperties = { + ...LABEL_STYLE_BASE, + color, + top: (node.y0 + node.y1) / 2, + ...(last ? { right: `calc(100% - ${node.x0 - LABEL_GAP}px)` } : { left: node.x1 + LABEL_GAP }), + } + return ( +
+ {shown} +
+ ) + })} + + ) +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx new file mode 100644 index 000000000000..2802cb691ad7 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import { TooltipSurface, TooltipSwatch } from '../../overlays/TooltipSurface' +import type { SankeyTooltipContext } from './types' + +export interface SankeyTooltipProps { + ctx: SankeyTooltipContext + valueFormatter?: (value: number) => React.ReactNode +} + +function defaultFormatter(value: number): string { + return value.toLocaleString() +} + +function formatShare(fraction: number): string { + return `${Math.round(fraction * 1000) / 10}%` +} + +/** Default tooltip: the node label, or `source → target` for a ribbon, then the value and its + * share of the total inflow. */ +export function SankeyTooltip({ + ctx, + valueFormatter = defaultFormatter, +}: SankeyTooltipProps): React.ReactElement { + const { hit, total } = ctx + const title = hit.kind === 'node' ? hit.node.label : `${hit.link.source.label} → ${hit.link.target.label}` + const value = hit.kind === 'node' ? hit.node.value : hit.link.value + const color = hit.kind === 'node' ? hit.node.color : hit.link.color + const share = total > 0 ? value / total : 0 + + return ( + +
+ + {title} +
+
+ {valueFormatter(value)} + {share > 0 ? ({formatShare(share)}) : null} +
+
+ ) +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts new file mode 100644 index 000000000000..e5fcaab75404 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -0,0 +1,108 @@ +import { mixColors } from '../../core/color-utils' +import type { SankeyChartLayout, SankeyHit, SankeyLinkDatum, SankeyNodeDatum } from './sankey-data' + +export interface DrawSankeyOptions { + linkOpacity: number +} + +/** Ribbon opacity while it, or a node it touches, is hovered. */ +const HOVER_LINK_OPACITY = 0.85 +/** The rest of the graph fades toward the background so the hovered flow stands out. */ +const HOVER_DIM_AMOUNT = 0.55 +const HOVER_HIGHLIGHT_TARGET = '#ffffff' +const HOVER_HIGHLIGHT_AMOUNT = 0.15 +const HOVER_DIM_TARGET_FALLBACK = '#ffffff' + +function traceLink(ctx: CanvasRenderingContext2D, link: SankeyLinkDatum): void { + const x0 = link.source.x1 + const x1 = link.target.x0 + const xm = (x0 + x1) / 2 + ctx.beginPath() + ctx.moveTo(x0, link.y0) + ctx.bezierCurveTo(xm, link.y0, xm, link.y1, x1, link.y1) +} + +function strokeLink(ctx: CanvasRenderingContext2D, link: SankeyLinkDatum, color: string, opacity: number): void { + traceLink(ctx, link) + ctx.save() + ctx.globalAlpha *= opacity + ctx.strokeStyle = color + // A sub-pixel ribbon disappears against the node it leaves; keep every flow visible. + ctx.lineWidth = Math.max(1, link.width) + ctx.stroke() + ctx.restore() +} + +function fillNode(ctx: CanvasRenderingContext2D, node: SankeyNodeDatum, color: string): void { + ctx.fillStyle = color + ctx.fillRect(node.x0, node.y0, node.x1 - node.x0, Math.max(1, node.y1 - node.y0)) +} + +/** Static layer: every ribbon under every node, in input order. */ +export function drawSankey(ctx: CanvasRenderingContext2D, layout: SankeyChartLayout, options: DrawSankeyOptions): void { + for (const link of layout.links) { + strokeLink(ctx, link, link.color, options.linkOpacity) + } + for (const node of layout.nodes) { + fillNode(ctx, node, node.color) + } +} + +/** Hover layer: dims the graph toward the background, then repaints the hovered node or ribbon + * and everything connected to it at full strength. Returns false when nothing is hovered. */ +export function drawSankeyHover( + ctx: CanvasRenderingContext2D, + layout: SankeyChartLayout, + hit: SankeyHit | null, + options: DrawSankeyOptions & { backgroundColor?: string; progress: number } +): boolean { + if (!hit) { + return false + } + const dimTarget = options.backgroundColor || HOVER_DIM_TARGET_FALLBACK + const dim = options.progress * HOVER_DIM_AMOUNT + + const activeLinks = new Set() + const activeNodes = new Set() + if (hit.kind === 'node') { + const node = layout.nodes[hit.index] + activeNodes.add(node) + for (const link of layout.links) { + if (link.source === node || link.target === node) { + activeLinks.add(link) + activeNodes.add(link.source) + activeNodes.add(link.target) + } + } + } else { + const link = layout.links[hit.index] + activeLinks.add(link) + activeNodes.add(link.source) + activeNodes.add(link.target) + } + + // The dim fill must be opaque: a translucent repaint composites over the full-color static + // layer and does not dim at all. + for (const link of layout.links) { + if (!activeLinks.has(link)) { + strokeLink(ctx, link, mixColors(link.color, dimTarget, dim), options.linkOpacity) + } + } + for (const node of layout.nodes) { + if (!activeNodes.has(node)) { + fillNode(ctx, node, mixColors(node.color, dimTarget, dim)) + } + } + for (const link of activeLinks) { + const opacity = options.linkOpacity + (HOVER_LINK_OPACITY - options.linkOpacity) * options.progress + strokeLink(ctx, link, link.color, opacity) + } + for (const node of activeNodes) { + const highlight = hit.kind === 'node' && node === layout.nodes[hit.index] + const color = highlight + ? mixColors(node.color, HOVER_HIGHLIGHT_TARGET, HOVER_HIGHLIGHT_AMOUNT * options.progress) + : node.color + fillNode(ctx, node, color) + } + return true +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-context.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-context.ts new file mode 100644 index 000000000000..5bf240a490db --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react' + +import type { SankeyChartLayout } from './sankey-data' + +/** Layout-stable values exposed to Sankey overlays (node labels, column headers, custom + * decorations). Identity does not change on hover. */ +export interface SankeyLayoutContextValue { + layout: SankeyChartLayout + /** Returns the current canvas bounding rect, or null if the canvas is unmounted. */ + canvasBounds: () => DOMRect | null +} + +export const SankeyLayoutContext = createContext(null) + +/** Subscribes to the Sankey layout. Throws if used outside a ``. */ +export function useSankeyLayout(): SankeyLayoutContextValue< + NodeMeta, + LinkMeta +> { + const ctx = useContext(SankeyLayoutContext) + if (!ctx) { + throw new Error('useSankeyLayout must be used inside ') + } + return ctx as SankeyLayoutContextValue +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts new file mode 100644 index 000000000000..2755d240d7a9 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -0,0 +1,99 @@ +import { computeSankeyLayout, sankeyHitAt } from './sankey-data' +import type { ComputeSankeyLayoutOptions, SankeyLinkInput, SankeyNodeInput } from './sankey-data' + +const PLOT = { plotLeft: 0, plotTop: 0, plotWidth: 600, plotHeight: 300 } + +const NODES: SankeyNodeInput[] = [ + { id: 'start' }, + { id: 'a', label: 'Tool A' }, + { id: 'b', label: 'Tool B' }, + { id: 'done', label: 'Completed' }, +] +const LINKS: SankeyLinkInput[] = [ + { source: 'start', target: 'a', value: 30 }, + { source: 'start', target: 'b', value: 10 }, + { source: 'a', target: 'done', value: 30 }, + { source: 'b', target: 'done', value: 10 }, +] + +function layoutOf( + overrides: Partial> = {} +): ReturnType { + return computeSankeyLayout({ + nodes: NODES, + links: LINKS, + plot: PLOT, + nodeWidth: 10, + nodePadding: 8, + nodeAlign: 'justify', + preserveNodeOrder: false, + colorForLabel: () => '#111111', + resolveColor: (c) => c, + ...overrides, + }) +} + +describe('computeSankeyLayout', () => { + it('places nodes in columns by depth and sums flow into node values', () => { + const layout = layoutOf() + const byId = Object.fromEntries(layout.nodes.map((n) => [n.id, n])) + expect(byId.start.column).toBe(0) + expect(byId.a.column).toBe(1) + expect(byId.b.column).toBe(1) + expect(byId.done.column).toBe(2) + expect(layout.columnCount).toBe(3) + expect(byId.start.value).toBe(40) + expect(byId.done.value).toBe(40) + // The total is the inflow of the source nodes, which is what shares are measured against. + expect(layout.total).toBe(40) + }) + + it('keeps a flow that ends early in its own column under left alignment', () => { + const links: SankeyLinkInput[] = [...LINKS, { source: 'start', target: 'ended', value: 5 }] + const nodes: SankeyNodeInput[] = [...NODES, { id: 'ended', label: 'Ended' }] + const justified = layoutOf({ nodes, links }) + const left = layoutOf({ nodes, links, nodeAlign: 'left' }) + expect(justified.nodes.find((n) => n.id === 'ended')?.column).toBe(2) + expect(left.nodes.find((n) => n.id === 'ended')?.column).toBe(1) + }) + + it('resolves node colors by label and defaults link color to the source node', () => { + const layout = layoutOf({ + nodes: [...NODES.slice(0, 3), { id: 'done', label: 'Completed', color: 'var(--success)' }], + colorForLabel: (label) => (label === 'Tool A' ? '#aaaaaa' : '#bbbbbb'), + resolveColor: (c) => (c.startsWith('var(') ? '#00ff00' : c), + }) + const byId = Object.fromEntries(layout.nodes.map((n) => [n.id, n])) + expect(byId.a.color).toBe('#aaaaaa') + expect(byId.done.color).toBe('#00ff00') + const aToDone = layout.links.find((l) => l.source.id === 'a' && l.target.id === 'done') + expect(aToDone?.color).toBe('#aaaaaa') + }) + + it('returns an empty layout without links and throws on a link to a missing node', () => { + expect(layoutOf({ links: [] }).nodes).toHaveLength(0) + expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow('missing: nope') + }) +}) + +describe('sankeyHitAt', () => { + const layout = layoutOf() + const node = (id: string): NonNullable<(typeof layout.nodes)[number]> => layout.nodes.find((n) => n.id === id)! + + it('resolves a node box, a ribbon between columns, and empty space', () => { + const start = node('start') + const nodeHit = sankeyHitAt(layout, { x: (start.x0 + start.x1) / 2, y: (start.y0 + start.y1) / 2 }) + expect(nodeHit).toEqual({ kind: 'node', index: layout.nodes.indexOf(start) }) + + const aToDone = layout.links.findIndex((l) => l.source.id === 'a' && l.target.id === 'done') + const link = layout.links[aToDone] + // Mid-ribbon, the curve passes through the average of its end y values. + const linkHit = sankeyHitAt(layout, { + x: (link.source.x1 + link.target.x0) / 2, + y: (link.y0 + link.y1) / 2, + }) + expect(linkHit).toEqual({ kind: 'link', index: aToDone }) + + expect(sankeyHitAt(layout, { x: -50, y: -50 })).toBeNull() + }) +}) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts new file mode 100644 index 000000000000..d1ccff8bfa04 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -0,0 +1,296 @@ +import { sankeyCenter, sankeyJustify, sankeyLayout, sankeyLeft, sankeyRight } from './sankey-layout' + +/** A node of the flow graph. `id` must be unique; a node that occurs at several stages of a + * flow (the same tool called twice in a row) needs one node per stage, so prefix the id with the + * stage. Nodes with the same `label` share a palette color, so the repeated tool still reads + * as one thing. */ +export interface SankeyNodeInput { + id: string + /** Display label. Defaults to `id`. */ + label?: string + /** CSS color (hex, rgb, or `var(--…)`, resolved by the chart). Omit to pick a palette color + * by label. */ + color?: string + /** Consumer data handed back through tooltips and click handlers. */ + meta?: Meta +} + +/** A directed flow from `source` to `target` (both node ids). The graph must be acyclic. */ +export interface SankeyLinkInput { + source: string + target: string + /** Flow magnitude. Drives the ribbon width and the node heights. */ + value: number + /** CSS color for the ribbon. Defaults to the source node's color. */ + color?: string + /** Consumer data handed back through tooltips and click handlers. */ + meta?: Meta +} + +/** How nodes spread across columns when a flow ends early. Mirrors d3-sankey's alignments. */ +export type SankeyNodeAlign = 'left' | 'right' | 'center' | 'justify' + +/** A laid-out node: the input plus its pixel box, column, and total flow. */ +export interface SankeyNodeDatum { + id: string + label: string + color: string + meta?: Meta + /** Zero-based column, left to right. */ + column: number + /** Sum of the larger side's link values (in or out). */ + value: number + x0: number + x1: number + y0: number + y1: number +} + +/** A laid-out link: the input plus the ribbon geometry. `y0` is the centerline y at the source + * edge, `y1` at the target edge; `width` is the ribbon thickness. */ +export interface SankeyLinkDatum { + source: SankeyNodeDatum + target: SankeyNodeDatum + value: number + color: string + meta?: LinkMeta + y0: number + y1: number + width: number +} + +export interface SankeyChartLayout { + nodes: SankeyNodeDatum[] + links: SankeyLinkDatum[] + /** Number of columns the nodes occupy. */ + columnCount: number + /** Pixel x of each column's left edge, so overlays can place column headers. */ + columnX: number[] + /** Total flow entering the graph: the summed value of the nodes with no incoming link. + * Tooltips report each node and link as a share of this. */ + total: number + nodeWidth: number +} + +export interface SankeyPlotBox { + plotLeft: number + plotTop: number + plotWidth: number + plotHeight: number +} + +export interface ComputeSankeyLayoutOptions { + nodes: readonly SankeyNodeInput[] + links: readonly SankeyLinkInput[] + plot: SankeyPlotBox + nodeWidth: number + nodePadding: number + nodeAlign: SankeyNodeAlign + /** Keep the input order of nodes within a column instead of sorting by flow position. */ + preserveNodeOrder: boolean + /** Resolves a node's fill when it sets no `color`. Receives the node's label. */ + colorForLabel: (label: string) => string + /** Applied to every color before it reaches the canvas, so `var(--…)` inputs resolve. */ + resolveColor: (color: string) => string +} + +interface LayoutNodeProps { + id: string + label: string + color: string + meta?: unknown + [key: string]: unknown +} + +interface LayoutLinkProps { + source: string | LayoutNodeProps + target: string | LayoutNodeProps + value: number + color?: string + meta?: unknown + [key: string]: unknown +} + +const ALIGNMENTS = { + left: sankeyLeft, + right: sankeyRight, + center: sankeyCenter, + justify: sankeyJustify, +} + +export const EMPTY_SANKEY_LAYOUT: SankeyChartLayout = { + nodes: [], + links: [], + columnCount: 0, + columnX: [], + total: 0, + nodeWidth: 0, +} + +/** Lays the graph out inside `plot`. Pure: safe to call from a memo or a test. Throws when a link + * names a missing node or the graph has a cycle, so a consumer bug surfaces through the chart's + * error boundary instead of drawing nothing. */ +export function computeSankeyLayout({ + nodes, + links, + plot, + nodeWidth, + nodePadding, + nodeAlign, + preserveNodeOrder, + colorForLabel, + resolveColor, +}: ComputeSankeyLayoutOptions): SankeyChartLayout { + if (nodes.length === 0 || links.length === 0 || plot.plotWidth <= 0 || plot.plotHeight <= 0) { + return EMPTY_SANKEY_LAYOUT as SankeyChartLayout + } + + // The engine mutates its inputs, so hand it fresh objects. + const engineNodes: LayoutNodeProps[] = nodes.map((node) => { + const label = node.label ?? node.id + return { id: node.id, label, color: resolveColor(node.color || colorForLabel(label)), meta: node.meta } + }) + const engineLinks: LayoutLinkProps[] = links.map((link) => ({ + source: link.source, + target: link.target, + value: link.value, + color: link.color, + meta: link.meta, + })) + + const graph = sankeyLayout() + .nodeId((node) => node.id) + .nodeAlign(ALIGNMENTS[nodeAlign]) + .nodeSort(preserveNodeOrder ? null : undefined) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .extent([ + [plot.plotLeft, plot.plotTop], + [plot.plotLeft + plot.plotWidth, plot.plotTop + plot.plotHeight], + ])({ nodes: engineNodes, links: engineLinks }) + + const datumByIndex = new Map>() + const outNodes = graph.nodes.map((node): SankeyNodeDatum => { + const datum: SankeyNodeDatum = { + id: node.id, + label: node.label, + color: node.color, + meta: node.meta as NodeMeta | undefined, + column: node.layer, + value: node.value, + x0: node.x0, + x1: node.x1, + y0: node.y0, + y1: node.y1, + } + datumByIndex.set(node.index, datum) + return datum + }) + + const outLinks = graph.links.map((link): SankeyLinkDatum => { + const source = datumByIndex.get(link.source.index)! + const target = datumByIndex.get(link.target.index)! + return { + source, + target, + value: link.value, + color: link.color ? resolveColor(link.color) : source.color, + meta: link.meta as LinkMeta | undefined, + y0: link.y0, + y1: link.y1, + width: link.width, + } + }) + + const columnCount = Math.max(0, ...graph.nodes.map((node) => node.layer + 1)) + const columnX: number[] = [] + for (const node of graph.nodes) { + columnX[node.layer] = node.x0 + } + + const total = graph.nodes.filter((node) => node.targetLinks.length === 0).reduce((sum, node) => sum + node.value, 0) + + return { nodes: outNodes, links: outLinks, columnCount, columnX, total, nodeWidth } +} + +export type SankeyHit = { kind: 'node'; index: number } | { kind: 'link'; index: number } + +/** Resolves what sits under the cursor: a node box first, then the nearest ribbon whose thickness + * covers the cursor, else `null`. Ribbons are the cubic curves the canvas draws (control points + * at the horizontal midpoint), so the test follows the painted shape exactly. */ +export function sankeyHitAt( + layout: SankeyChartLayout, + cursor: { x: number; y: number } +): SankeyHit | null { + for (let i = 0; i < layout.nodes.length; i++) { + const node = layout.nodes[i] + if (cursor.x >= node.x0 && cursor.x <= node.x1 && cursor.y >= node.y0 && cursor.y <= node.y1) { + return { kind: 'node', index: i } + } + } + let best: { index: number; distance: number } | null = null + for (let i = 0; i < layout.links.length; i++) { + const link = layout.links[i] + const startX = link.source.x1 + const endX = link.target.x0 + if (cursor.x < startX || cursor.x > endX || endX <= startX) { + continue + } + const t = solveBezierT(startX, endX, cursor.x) + const centerY = bezierY(link.y0, link.y1, t) + const distance = Math.abs(cursor.y - centerY) + if (distance <= Math.max(link.width, 1) / 2 && (!best || distance < best.distance)) { + best = { index: i, distance } + } + } + return best ? { kind: 'link', index: best.index } : null +} + +/** x(t) of the ribbon centerline, with both control points at the horizontal midpoint. */ +function bezierX(x0: number, x1: number, t: number): number { + const xm = (x0 + x1) / 2 + const u = 1 - t + return u * u * u * x0 + 3 * u * u * t * xm + 3 * u * t * t * xm + t * t * t * x1 +} + +/** y(t) of the ribbon centerline: the control points share the end points' y, so the curve eases + * from `y0` to `y1`. */ +export function bezierY(y0: number, y1: number, t: number): number { + const u = 1 - t + return u * u * u * y0 + 3 * u * u * t * y0 + 3 * u * t * t * y1 + t * t * t * y1 +} + +/** Inverts `bezierX` for a cursor x. `x(t)` is monotonic on [0, 1], so bisection converges. */ +function solveBezierT(x0: number, x1: number, x: number): number { + let lo = 0 + let hi = 1 + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2 + if (bezierX(x0, x1, mid) < x) { + lo = mid + } else { + hi = mid + } + } + return (lo + hi) / 2 +} + +/** Encodes a hit into the single `hoverIndex` the shared draw loop expects: nodes first, then + * links offset by the node count. */ +export function hitToHoverIndex(layout: SankeyChartLayout, hit: SankeyHit | null): number { + if (!hit) { + return -1 + } + return hit.kind === 'node' ? hit.index : layout.nodes.length + hit.index +} + +export function hoverIndexToHit(layout: SankeyChartLayout, hoverIndex: number): SankeyHit | null { + if (hoverIndex < 0) { + return null + } + if (hoverIndex < layout.nodes.length) { + return { kind: 'node', index: hoverIndex } + } + const linkIndex = hoverIndex - layout.nodes.length + return linkIndex < layout.links.length ? { kind: 'link', index: linkIndex } : null +} diff --git a/frontend/src/vendor/d3/sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts similarity index 94% rename from frontend/src/vendor/d3/sankey.ts rename to packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index e15e17a504b2..45a4506b6307 100644 --- a/frontend/src/vendor/d3/sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -8,14 +8,14 @@ * versions of the d3 modules we actually use. */ -import { Link, linkHorizontal } from 'd3' -import { max, min, sum } from 'd3' +import { max, min, sum } from 'd3-array' +import { type Link, linkHorizontal } from 'd3-shape' // ---- Public types ---- -export interface SankeyExtraProperties { - [key: string]: any -} +/** Consumer fields carried on nodes and links. Any object type qualifies; the layout adds its own + * geometry fields on top. */ +export type SankeyExtraProperties = object export type SankeyNode = N & { sourceLinks: Array> @@ -47,7 +47,9 @@ export interface SankeyGraph> } -export type SankeyInputGraph = { nodes: any[]; links: any[] } +/** Input graph. Link `source`/`target` may be node ids (resolved through `nodeId`) or node + * objects; the layout mutates both arrays in place, so pass copies of data you keep. */ +export type SankeyInputGraph = { nodes: object[]; links: object[] } export interface SankeyLayout { (graph: SankeyInputGraph): SankeyGraph @@ -125,7 +127,7 @@ function horizontalTarget(): Link< - any, + unknown, SankeyLink, [number, number] > { @@ -188,9 +190,9 @@ function computeLinkBreadths(): SankeyLayout { let x0 = 0, y0 = 0, @@ -203,10 +205,10 @@ export default function sankey< let id: (d: SankeyNode) => string | number = (d) => d.index let align: (node: SankeyNode, n: number) => number = sankeyJustify let sort: ((a: SankeyNode, b: SankeyNode) => number) | null | undefined - let nodesFn: (graph: SankeyInputGraph) => SankeyNode[] = (graph) => graph.nodes - let linksFn: (graph: SankeyInputGraph) => SankeyLink[] = (graph) => graph.links + let nodesFn: (graph: SankeyInputGraph) => SankeyNode[] = (graph) => graph.nodes as SankeyNode[] + let linksFn: (graph: SankeyInputGraph) => SankeyLink[] = (graph) => graph.links as SankeyLink[] - let sankeyLayout: SankeyLayout + let self: SankeyLayout function layout(graph: SankeyInputGraph): SankeyGraph { const g = { nodes: nodesFn(graph), links: linksFn(graph) } @@ -232,7 +234,7 @@ export default function sankey< ): ((graph: SankeyInputGraph) => SankeyNode[]) | SankeyLayout { if (fn) { nodesFn = typeof fn === 'function' ? fn : () => fn - return sankeyLayout + return self } return nodesFn } @@ -245,7 +247,7 @@ export default function sankey< ): ((graph: SankeyInputGraph) => SankeyLink[]) | SankeyLayout { if (fn) { linksFn = typeof fn === 'function' ? fn : () => fn - return sankeyLayout + return self } return linksFn } @@ -257,7 +259,7 @@ export default function sankey< ): ((node: SankeyNode) => string | number) | SankeyLayout { if (fn) { id = fn - return sankeyLayout + return self } return id } @@ -269,7 +271,7 @@ export default function sankey< ): ((node: SankeyNode, n: number) => number) | SankeyLayout { if (fn) { align = fn - return sankeyLayout + return self } return align } @@ -283,7 +285,7 @@ export default function sankey< ): ((a: SankeyNode, b: SankeyNode) => number) | undefined | null | SankeyLayout { if (arguments.length) { sort = fn - return sankeyLayout + return self } return sort } @@ -293,7 +295,7 @@ export default function sankey< function nodeWidthAccessor(width?: number): number | SankeyLayout { if (width !== undefined) { dx = width - return sankeyLayout + return self } return dx } @@ -303,7 +305,7 @@ export default function sankey< function nodePaddingAccessor(padding?: number): number | SankeyLayout { if (padding !== undefined) { dy = py = padding - return sankeyLayout + return self } return dy } @@ -315,7 +317,7 @@ export default function sankey< x0 = y0 = 0 x1 = size[0] y1 = size[1] - return sankeyLayout + return self } return [x1 - x0, y1 - y0] } @@ -330,7 +332,7 @@ export default function sankey< y0 = extent[0][1] x1 = extent[1][0] y1 = extent[1][1] - return sankeyLayout + return self } return [ [x0, y0], @@ -338,7 +340,7 @@ export default function sankey< ] satisfies [[number, number], [number, number]] } - sankeyLayout = Object.assign(layout, { + self = Object.assign(layout, { update, nodes: nodesAccessor, links: linksAccessor, @@ -620,5 +622,5 @@ export default function sankey< return y } - return sankeyLayout + return self } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts new file mode 100644 index 000000000000..01656c5913f4 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -0,0 +1,62 @@ +import type React from 'react' + +import type { ChartMargins, ChartTheme, TooltipContext } from '../../core/types' +import type { SankeyLinkDatum, SankeyLinkInput, SankeyNodeAlign, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' + +/** What the cursor is over, with the laid-out datum attached. */ +export type SankeyTooltipHit = + | { kind: 'node'; node: SankeyNodeDatum } + | { kind: 'link'; link: SankeyLinkDatum } + +/** Tooltip context for a Sankey hover: the base `TooltipContext` (one `seriesData` row for the + * hovered node or ribbon) plus the resolved `hit` and the graph `total` for share math. */ +export type SankeyTooltipContext = TooltipContext & { + hit: SankeyTooltipHit + total: number +} + +export interface SankeyChartConfig { + /** Node rectangle width in px. Defaults to 12. */ + nodeWidth?: number + /** Vertical gap between nodes in one column, in px. Defaults to 8. */ + nodePadding?: number + /** Column placement for flows that end early. `justify` (default) pushes terminal nodes to the + * last column; `left` keeps every node at its own depth, which is the right choice when the + * columns are stages (first tool, second tool, …). */ + nodeAlign?: SankeyNodeAlign + /** Keep nodes in input order within each column instead of ordering them to untangle ribbons. */ + preserveNodeOrder?: boolean + /** Header text for each column, left to right. Reserves room above the plot. */ + columnLabels?: string[] + /** Draw each node's label beside it. Defaults to true. */ + showNodeLabels?: boolean + /** Append the node's value to its label. Defaults to false. */ + showNodeValues?: boolean + /** Resting ribbon opacity, 0..1. Defaults to 0.4. */ + linkOpacity?: number + /** Formats values in node labels and the default tooltip. Defaults to `toLocaleString`. */ + valueFormatter?: (value: number) => string + tooltip?: { + /** Show the tooltip on hover. Defaults to true. */ + enabled?: boolean + } + /** Per-side margin overrides. Should be referentially stable. */ + margins?: Partial +} + +export interface SankeyChartProps { + nodes: SankeyNodeInput[] + links: SankeyLinkInput[] + theme: ChartTheme + config?: SankeyChartConfig + /** Replaces the default tooltip content. */ + tooltip?: (ctx: SankeyTooltipContext) => React.ReactNode + onNodeClick?: (node: SankeyNodeDatum) => void + onLinkClick?: (link: SankeyLinkDatum) => void + className?: string + /** `data-attr` applied to the chart wrapper. */ + dataAttr?: string + /** Overlays rendered above the canvas. Read the layout with `useSankeyLayout()`. */ + children?: React.ReactNode + onError?: (error: Error, info: React.ErrorInfo) => void +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts new file mode 100644 index 000000000000..ba5116a111d7 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -0,0 +1,151 @@ +import React, { useCallback, useMemo } from 'react' + +import { useLatest } from '../../core/hooks/useLatest' +import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' +import type { Series, TooltipContext } from '../../core/types' +import { hitToHoverIndex, hoverIndexToHit, sankeyHitAt } from './sankey-data' +import type { SankeyChartLayout, SankeyHit, SankeyLinkDatum, SankeyNodeDatum } from './sankey-data' +import type { SankeyTooltipContext, SankeyTooltipHit } from './types' + +interface UseSankeyInteractionOptions { + layout: SankeyChartLayout + canvasRef: React.RefObject + wrapperRef: React.RefObject + showTooltip: boolean + onNodeClick?: (node: SankeyNodeDatum) => void + onLinkClick?: (link: SankeyLinkDatum) => void +} + +interface UseSankeyInteractionResult { + hoverIndex: number + hoverPosition: { x: number; y: number } | null + tooltipCtx: SankeyTooltipContext | null + handlers: { + onMouseMove: (e: React.MouseEvent) => void + onMouseLeave: () => void + onClick: () => void + } +} + +function resolveHit( + layout: SankeyChartLayout, + hit: SankeyHit +): SankeyTooltipHit { + return hit.kind === 'node' + ? { kind: 'node', node: layout.nodes[hit.index] } + : { kind: 'link', link: layout.links[hit.index] } +} + +function buildTooltipCtx( + layout: SankeyChartLayout, + hit: SankeyHit, + cursor: { x: number; y: number } | null, + canvasBounds: DOMRect +): SankeyTooltipContext { + const resolved = resolveHit(layout, hit) + const label = + resolved.kind === 'node' ? resolved.node.label : `${resolved.link.source.label} → ${resolved.link.target.label}` + const value = resolved.kind === 'node' ? resolved.node.value : resolved.link.value + const color = resolved.kind === 'node' ? resolved.node.color : resolved.link.color + const meta: NodeMeta | LinkMeta | undefined = resolved.kind === 'node' ? resolved.node.meta : resolved.link.meta + const anchor = + resolved.kind === 'node' + ? { x: resolved.node.x1, y: (resolved.node.y0 + resolved.node.y1) / 2 } + : { + x: (resolved.link.source.x1 + resolved.link.target.x0) / 2, + y: (resolved.link.y0 + resolved.link.y1) / 2, + } + // One synthetic series row keeps the shared tooltip plumbing (equivalence checks, the + // default renderer fallback) working on a chart that has no series. + const series: Series = { key: `${hit.kind}:${hit.index}`, label, data: [value], color, meta } + return { + dataIndex: hitToHoverIndex(layout, hit), + label, + seriesData: [{ series, value, color }], + position: anchor, + hoverPosition: cursor, + canvasBounds, + isPinned: false, + hit: resolved, + total: layout.total, + } +} + +export function useSankeyInteraction({ + layout, + canvasRef, + wrapperRef, + showTooltip, + onNodeClick, + onLinkClick, +}: UseSankeyInteractionOptions): UseSankeyInteractionResult { + type Ctx = SankeyTooltipContext + const layoutRef = useLatest(layout) + + const rebuildPinnedCtx = useCallback( + (prev: TooltipContext): Ctx | null => { + const hit = hoverIndexToHit(layoutRef.current, prev.dataIndex) + if (!hit) { + return null + } + const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() + return buildTooltipCtx(layoutRef.current, hit, prev.hoverPosition, canvasBounds) + }, + [layoutRef, canvasRef] + ) + + const { hoverIndex, hoverPosition, tooltipCtx, setHover, setTooltipCtx, clearTooltip } = useTooltipLifecycle< + NodeMeta | LinkMeta + >({ + wrapperRef, + rebuildPinnedCtx, + rebuildDeps: [layout], + }) + + const hoverIndexRef = useLatest(hoverIndex) + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + const current = layoutRef.current + if (current.nodes.length === 0) { + return + } + const rect = e.currentTarget.getBoundingClientRect() + const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } + const hit = sankeyHitAt(current, cursor) + if (!hit) { + clearTooltip() + return + } + setHover(hitToHoverIndex(current, hit), cursor) + if (showTooltip) { + const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() + setTooltipCtx(buildTooltipCtx(current, hit, cursor, canvasBounds)) + } + }, + [layoutRef, showTooltip, setHover, setTooltipCtx, clearTooltip, canvasRef] + ) + + const onMouseLeave = useCallback(() => { + clearTooltip() + }, [clearTooltip]) + + const onClick = useCallback(() => { + const hit = hoverIndexToHit(layoutRef.current, hoverIndexRef.current) + if (!hit) { + return + } + const resolved = resolveHit(layoutRef.current, hit) + if (resolved.kind === 'node') { + onNodeClick?.(resolved.node) + } else { + onLinkClick?.(resolved.link) + } + }, [layoutRef, hoverIndexRef, onNodeClick, onLinkClick]) + + const handlers = useMemo(() => ({ onMouseMove, onMouseLeave, onClick }), [onMouseMove, onMouseLeave, onClick]) + + // The lifecycle stores the base context; every value it holds was built by `buildTooltipCtx`, + // so the extra Sankey fields are present. + return { hoverIndex, hoverPosition, tooltipCtx: tooltipCtx as Ctx | null, handlers } +} diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index ee084ba0d28a..ee8f2588b27b 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -74,6 +74,27 @@ A 2D density grid: `xLabels` by `yLabels` (row 0 at the bottom), `cells[row][col Log ramp by default; `colorScale: 'linear'` to opt out. A single-cell tooltip resolves from the cursor, `onCellClick` reports `{ xIndex, yIndex, value }`, and `onBrush` reports row and column index ranges. +## SankeyChart + +Flow between stages: `nodes: { id, label?, color?, meta? }[]` and `links: { source, target, value, color?, meta? }[]`, where `source` and `target` are node ids. +There is no `series` or `labels`. + +- The graph must be acyclic and every link must name existing nodes; the layout throws otherwise, and the chart's error boundary reports it through `onError`. + A node that recurs at several stages (the same tool called twice) needs one id per stage, so prefix ids with the stage. +- Nodes that share a `label` share a palette color, so a repeated tool keeps one hue across columns. + A link without `color` takes its source node's color. + `var(--…)` colors resolve inside the chart. +- `nodeAlign` decides where a flow that ends early sits: `justify` (default) moves terminal nodes to the last column; `left` keeps each node at its own depth, which reads right when columns are stages. + `preserveNodeOrder` keeps input order within a column instead of untangling ribbons. +- `columnLabels` renders headers above each column and reserves room for them. + Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. + `showNodeValues` appends the node value. +- Hovering a node lifts its ribbons and dims the rest of the graph; hovering a ribbon lifts just that ribbon. + The default tooltip shows the node label or `source → target`, the value, and its share of `layout.total` (the summed inflow of the nodes with no incoming link). + `onNodeClick` and `onLinkClick` receive the laid-out datum with its `meta`. +- Custom overlays read `useSankeyLayout()` for the positioned `nodes`, `links`, `columnX`, and `total`. +- The layout engine ships on its own as `sankeyLayout` (plus the `sankeyLeft` / `sankeyJustify` alignments and `sankeyLinkHorizontal`) for hosts that draw their own SVG. + ## Sparkline An axis-less preset over `LineChart` (default, gradient-filled line) or stacked bars via `type: 'bar'`. diff --git a/packages/quill/packages/charts/src/index.ts b/packages/quill/packages/charts/src/index.ts index a4284473a1a2..64565f813971 100644 --- a/packages/quill/packages/charts/src/index.ts +++ b/packages/quill/packages/charts/src/index.ts @@ -113,6 +113,43 @@ export { useRadialLayout } from './core/radial-context' export type { RadialLayoutContextValue } from './core/radial-context' export type { RadialSlicePayload } from './core/hooks/useRadialInteraction' +// Sankey +export { SankeyChart } from './charts/SankeyChart/SankeyChart' +export type { + SankeyChartConfig, + SankeyChartProps, + SankeyTooltipContext, + SankeyTooltipHit, +} from './charts/SankeyChart/types' +export type { + SankeyChartLayout, + SankeyLinkDatum, + SankeyLinkInput, + SankeyNodeAlign, + SankeyNodeDatum, + SankeyNodeInput, +} from './charts/SankeyChart/sankey-data' +export { SankeyTooltip } from './charts/SankeyChart/SankeyTooltip' +export type { SankeyTooltipProps } from './charts/SankeyChart/SankeyTooltip' +export { useSankeyLayout } from './charts/SankeyChart/sankey-context' +export type { SankeyLayoutContextValue } from './charts/SankeyChart/sankey-context' +// The layout engine on its own, for hosts that draw their own SVG (user paths). +export { + sankeyCenter, + sankeyJustify, + sankeyLayout, + sankeyLeft, + sankeyLinkHorizontal, + sankeyRight, +} from './charts/SankeyChart/sankey-layout' +export type { + SankeyGraph, + SankeyInputGraph, + SankeyLayout, + SankeyLink, + SankeyNode, +} from './charts/SankeyChart/sankey-layout' + // Chart context (for custom overlay children) export { useChart, useChartHover, useChartLayout } from './core/chart-context' export type { BaseChartContext, ChartHoverContextValue, ChartLayoutContextValue } from './core/chart-context' @@ -206,8 +243,4 @@ export { ChartLegend } from './components/Legend/ChartLegend' export type { ChartLegendProps } from './components/Legend/ChartLegend' export { legendItemsFromSeries } from './components/Legend/legendItemsFromSeries' export { useChartLegend, applyHiddenSeries } from './components/Legend/useChartLegend' -export type { - ChartLegendRenderProps, - ChartLegendState, - LegendItemControls, -} from './components/Legend/useChartLegend' +export type { ChartLegendRenderProps, ChartLegendState, LegendItemControls } from './components/Legend/useChartLegend' diff --git a/packages/quill/packages/charts/src/testing/accessor.ts b/packages/quill/packages/charts/src/testing/accessor.ts index 59692924e6ec..4a7a003372b2 100644 --- a/packages/quill/packages/charts/src/testing/accessor.ts +++ b/packages/quill/packages/charts/src/testing/accessor.ts @@ -91,6 +91,10 @@ export interface HogChart { slopeSeriesLabels(): string[] /** Slope-chart legend rows — label plus the per-series change. Empty when the legend is hidden. */ slopeLegendItems(): SlopeLegendItemSummary[] + /** Node labels rendered by a Sankey chart, in layout order. */ + sankeyNodeLabels(): string[] + /** Column headers rendered by a Sankey chart, left to right. */ + sankeyColumnLabels(): string[] /** Annotation badges currently rendered. */ annotationBadges(): HTMLElement[] /** Fire a `mouseMove` over the data point at `index`. Only available when the chart was @@ -242,6 +246,14 @@ export function getHogChart( } }) }, + sankeyNodeLabels: () => + Array.from(wrapper.querySelectorAll('[data-attr="hog-chart-sankey-node-label"]')).map( + (el) => el.textContent ?? '' + ), + sankeyColumnLabels: () => + Array.from(wrapper.querySelectorAll('[data-attr="hog-chart-sankey-column-label"]')).map( + (el) => el.textContent ?? '' + ), annotationBadges: () => Array.from(wrapper.querySelectorAll('.AnnotationsBadge')), hoverAtIndex(index: number): void { if (totalLabels === undefined) { diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index 50d6a7bfc0c1..f53411b042ec 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -1,41 +1,30 @@ import { useMemo } from 'react' -import { Tooltip } from '@posthog/lemon-ui' -import type { ChartTheme } from '@posthog/quill-charts' +import { SankeyChart } from '@posthog/quill-charts' +import type { ChartTheme, SankeyChartConfig, SankeyLinkInput, SankeyNodeInput } from '@posthog/quill-charts' import { useChartTheme } from 'lib/charts/hooks' -import sankey, { sankeyLeft, sankeyLinkHorizontal } from '~/vendor/d3/sankey' - -import type { MCPIntentClusterJourneyApi, MCPIntentClusterJourneyPathApi } from '../generated/api.schemas' +import type { MCPIntentClusterJourneyApi } from '../generated/api.schemas' +import { + buildJourneyGraph, + describeJourneyPath, + JOURNEY_COLUMN_LABELS, + type JourneyLinkMeta, + type JourneyNodeKind, + type JourneyNodeMeta, +} from './clusterJourneyGraph' import { ERROR_SERIES, PRIMARY_SERIES, seriesColor } from './seriesColors' -const WIDTH = 720 -const HEIGHT = 300 -const COLUMN_LABELS = ['Init', '1st tool', '2nd tool', '3rd tool', '4th tool', 'Outcome'] -const ENDED_LABEL = 'Ended' - -type NodeKind = 'init' | 'tool' | 'ended' | 'completed' | 'error' - -interface JourneyNode { - name: string - column: number - kind: NodeKind +// Flows that end early stay in their own stage column instead of being pushed to the outcome. +const CHART_CONFIG: SankeyChartConfig = { + columnLabels: JOURNEY_COLUMN_LABELS, + nodeAlign: 'left', + preserveNodeOrder: true, + linkOpacity: 0.45, } -interface JourneyLink { - source: number - target: number - value: number - outcome: 'completed' | 'error' -} - -function describePath(path: MCPIntentClusterJourneyPathApi): string { - const labels = path.steps.map((step) => step ?? ENDED_LABEL) - return labels.filter((label, idx) => label !== ENDED_LABEL || idx === labels.indexOf(ENDED_LABEL)).join(' → ') -} - -function nodeFill(kind: NodeKind, theme: ChartTheme): string { +function nodeFill(kind: JourneyNodeKind, theme: ChartTheme): string { switch (kind) { case 'init': return 'var(--muted)' @@ -51,93 +40,30 @@ function nodeFill(kind: NodeKind, theme: ChartTheme): string { } } -function buildGraph(paths: readonly MCPIntentClusterJourneyPathApi[]): { - nodes: JourneyNode[] - links: JourneyLink[] -} { - const nodeIndex = new Map() - const nodes: JourneyNode[] = [] - - const getNode = (column: number, name: string, kind: NodeKind): number => { - const key = `${column}::${name}` - const existing = nodeIndex.get(key) - if (existing !== undefined) { - return existing - } - const idx = nodes.length - nodeIndex.set(key, idx) - nodes.push({ name, column, kind }) - return idx - } - - const linkMap = new Map() - const initIdx = getNode(0, 'Init', 'init') - - for (const path of paths) { - const columns: { name: string; kind: NodeKind }[] = [{ name: 'Init', kind: 'init' }] - for (const step of path.steps) { - if (step === null) { - columns.push({ name: ENDED_LABEL, kind: 'ended' }) - } else { - columns.push({ name: step, kind: 'tool' }) - } - } - columns.push({ - name: path.outcome === 'error' ? 'Error' : 'Completed', - kind: path.outcome === 'error' ? 'error' : 'completed', - }) - - for (let i = 0; i < columns.length - 1; i++) { - const sourceCol = i === 0 ? 0 : i - const targetCol = sourceCol + 1 - const src = sourceCol === 0 ? initIdx : getNode(sourceCol, columns[i].name, columns[i].kind) - const tgt = getNode(targetCol, columns[i + 1].name, columns[i + 1].kind) - const linkKey = `${src}->${tgt}::${path.outcome}` - const existing = linkMap.get(linkKey) - if (existing) { - existing.value += path.count - } else { - linkMap.set(linkKey, { - source: src, - target: tgt, - value: path.count, - outcome: path.outcome === 'error' ? 'error' : 'completed', - }) - } - } - } - - return { nodes, links: Array.from(linkMap.values()) } -} - interface Props { journey: MCPIntentClusterJourneyApi | null | undefined } export function ClusterJourneySankey({ journey }: Props): JSX.Element | null { const theme = useChartTheme() - const graph = useMemo(() => (journey ? buildGraph(journey.paths) : { nodes: [], links: [] }), [journey]) - const layout = useMemo(() => { - if (graph.nodes.length === 0 || graph.links.length === 0) { + const graph = useMemo(() => { + if (!journey) { return null } - // d3-sankey mutates the inputs, so deep-copy before passing. - const layoutFn = sankey() - .nodeWidth(18) - .nodePadding(10) - .nodeAlign(sankeyLeft) - .extent([ - [4, 14], - [WIDTH - 4, HEIGHT - 14], - ]) - return layoutFn({ - nodes: graph.nodes.map((n) => ({ ...n })), - links: graph.links.map((l) => ({ ...l })), - }) - }, [graph]) - - if (!journey || !layout) { + const built = buildJourneyGraph(journey.paths) + const nodes: SankeyNodeInput[] = built.nodes.map((node) => ({ + ...node, + color: nodeFill(node.meta?.kind ?? 'tool', theme), + })) + const links: SankeyLinkInput[] = built.links.map((link) => ({ + ...link, + color: seriesColor(theme, link.meta?.outcome === 'error' ? ERROR_SERIES : PRIMARY_SERIES), + })) + return { nodes, links } + }, [journey, theme]) + + if (!journey || !graph || graph.links.length === 0) { return (
Not enough session data yet to plot a journey. Recompute after more sessions are summarised. @@ -145,106 +71,26 @@ export function ClusterJourneySankey({ journey }: Props): JSX.Element | null { ) } - const linkGen = sankeyLinkHorizontal() - return (
{journey.total_sessions} session{journey.total_sessions === 1 ? '' : 's'} · top {journey.paths.length}{' '} path{journey.paths.length === 1 ? '' : 's'}
-
- - - {COLUMN_LABELS.map((label, idx) => { - const colWidth = (WIDTH - 8) / COLUMN_LABELS.length - const x = 4 + colWidth * idx + colWidth / 2 - return ( - - {label} - - ) - })} - - - {layout.links.map((link, idx) => { - const path = linkGen(link) ?? '' - const stroke = seriesColor(theme, link.outcome === 'error' ? ERROR_SERIES : PRIMARY_SERIES) - const sourceName = (link.source as unknown as JourneyNode).name - const targetName = (link.target as unknown as JourneyNode).name - const pct = - journey.total_sessions > 0 - ? Math.round((link.value / journey.total_sessions) * 1000) / 10 - : 0 - return ( - - - {sourceName} → {targetName} - - - {link.value} session{link.value === 1 ? '' : 's'} ({pct}%) - - - Outcome: {link.outcome === 'error' ? 'error' : 'completed'} - -
- } - > - - - ) - })} - - - {layout.nodes.map((node, idx) => ( - - - - {node.name} - - - ))} - - +
+
{journey.leak ? (
Biggest leak:{' '} - {describePath(journey.leak)} drains{' '} + {describeJourneyPath(journey.leak)} drains{' '} {journey.leak.count} session{journey.leak.count === 1 ? '' : 's'} {' '} diff --git a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts new file mode 100644 index 000000000000..bbff0729c4f6 --- /dev/null +++ b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts @@ -0,0 +1,32 @@ +import type { MCPIntentClusterJourneyPathApi } from '../generated/api.schemas' +import { buildJourneyGraph } from './clusterJourneyGraph' + +const PATHS: MCPIntentClusterJourneyPathApi[] = [ + { steps: ['schema', 'sql', null], outcome: 'completed', count: 20 }, + { steps: ['schema', 'sql', null], outcome: 'error', count: 5 }, + { steps: ['sql', null, null], outcome: 'error', count: 3 }, +] + +describe('buildJourneyGraph', () => { + it('keeps one node per stage and one link per outcome between the same nodes', () => { + const graph = buildJourneyGraph(PATHS) + // The same tool at two stages is two nodes; Ended repeats per column it fills. + expect(graph.nodes.map((n) => n.id)).toEqual([ + '0::Init', + '1::schema', + '2::sql', + '3::Ended', + '4::Completed', + '4::Error', + '1::sql', + '2::Ended', + ]) + const schemaToSql = graph.links.filter((l) => l.source === '1::schema' && l.target === '2::sql') + expect(schemaToSql.map((l) => [l.meta?.outcome, l.value])).toEqual([ + ['completed', 20], + ['error', 5], + ]) + const intoError = graph.links.filter((l) => l.target === '4::Error') + expect(intoError.reduce((sum, l) => sum + l.value, 0)).toBe(8) + }) +}) diff --git a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts new file mode 100644 index 000000000000..86ce1cea15db --- /dev/null +++ b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts @@ -0,0 +1,73 @@ +import type { SankeyLinkInput, SankeyNodeInput } from '@posthog/quill-charts' + +import type { MCPIntentClusterJourneyPathApi } from '../generated/api.schemas' + +export const JOURNEY_COLUMN_LABELS = ['Init', '1st tool', '2nd tool', '3rd tool', '4th tool', 'Outcome'] +export const ENDED_LABEL = 'Ended' + +export type JourneyNodeKind = 'init' | 'tool' | 'ended' | 'completed' | 'error' +export type JourneyOutcome = 'completed' | 'error' + +export interface JourneyNodeMeta { + kind: JourneyNodeKind +} + +export interface JourneyLinkMeta { + outcome: JourneyOutcome +} + +export interface JourneyGraph { + nodes: SankeyNodeInput[] + links: SankeyLinkInput[] +} + +function outcomeOf(path: MCPIntentClusterJourneyPathApi): JourneyOutcome { + return path.outcome === 'error' ? 'error' : 'completed' +} + +/** + * Turns the top paths of a cluster into a stage-per-column flow graph. Every path spans the same + * number of columns: Init, one column per step (a step that never happened is `Ended`), then the + * outcome. Node ids carry the column so the same tool at two stages is two nodes, and links are + * split by outcome so error flows keep their own ribbon. + */ +export function buildJourneyGraph(paths: readonly MCPIntentClusterJourneyPathApi[]): JourneyGraph { + const nodes = new Map>() + const links = new Map>() + + const nodeId = (column: number, label: string, kind: JourneyNodeKind): string => { + const id = `${column}::${label}` + if (!nodes.has(id)) { + nodes.set(id, { id, label, meta: { kind } }) + } + return id + } + + for (const path of paths) { + const outcome = outcomeOf(path) + const stages: { label: string; kind: JourneyNodeKind }[] = [{ label: 'Init', kind: 'init' }] + for (const step of path.steps) { + stages.push(step === null ? { label: ENDED_LABEL, kind: 'ended' } : { label: step, kind: 'tool' }) + } + stages.push(outcome === 'error' ? { label: 'Error', kind: 'error' } : { label: 'Completed', kind: 'completed' }) + + for (let column = 0; column < stages.length - 1; column++) { + const source = nodeId(column, stages[column].label, stages[column].kind) + const target = nodeId(column + 1, stages[column + 1].label, stages[column + 1].kind) + const key = `${source}->${target}::${outcome}` + const existing = links.get(key) + if (existing) { + existing.value += path.count + } else { + links.set(key, { source, target, value: path.count, meta: { outcome } }) + } + } + } + + return { nodes: Array.from(nodes.values()), links: Array.from(links.values()) } +} + +export function describeJourneyPath(path: MCPIntentClusterJourneyPathApi): string { + const labels = path.steps.map((step) => step ?? ENDED_LABEL) + return labels.filter((label, idx) => label !== ENDED_LABEL || idx === labels.indexOf(ENDED_LABEL)).join(' → ') +} diff --git a/products/product_analytics/frontend/insights/paths/renderPaths.ts b/products/product_analytics/frontend/insights/paths/renderPaths.ts index 5420b1058ac8..073c26f9d22c 100644 --- a/products/product_analytics/frontend/insights/paths/renderPaths.ts +++ b/products/product_analytics/frontend/insights/paths/renderPaths.ts @@ -1,11 +1,18 @@ import * as d3 from 'd3' import { RefObject } from 'react' +import { + sankeyJustify, + sankeyLayout, + sankeyLinkHorizontal, + type SankeyLink, + type SankeyLayout, +} from '@posthog/quill-charts' + import { D3Selector } from 'lib/hooks/useD3' import { stripHTTP } from 'lib/utils/url' import { FunnelPathsFilter, PathsFilter } from '~/queries/schema/schema-general' -import sankey, { sankeyJustify, sankeyLinkHorizontal, type SankeyLink, type SankeyLayout } from '~/vendor/d3/sankey' // eslint-disable-next-line import/no-cycle import { FALLBACK_CANVAS_WIDTH } from './Paths' @@ -31,7 +38,7 @@ const createCanvas = (canvasRef: RefObject, width: number, heigh } const createSankeyGenerator = (width: number, height: number): SankeyLayout => { - return sankey() + return sankeyLayout() .nodeId((d) => d.name) .nodeAlign(sankeyJustify) .nodeSort(null) From f05b305c4f8744e9e43902ae63516bc0341ac7eb Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:08:30 +0100 Subject: [PATCH 02/80] fix(quill-charts): sankey hit detection and all-zero flow handling Fix zero-value node hit-testing: the draw layer floors node heights at Math.max(1, y1 - y0) so collapsed nodes stay visible, but the hit box tested raw coordinates. A zero-value node painted 1px tall but had zero height in the hit box, so it was never hoverable or clickable. Floor the hit box the same way the draw code does. Guard against all-zero flow: when every link value is zero, the layout engine produces NaN coordinates (0 * Infinity from ky computation). The chart then goes silently dead. Return EMPTY_SANKEY_LAYOUT when the computed total is zero, matching the behavior for empty graphs. Add test cases for both: a zero-value orphan node that must be hittable, and an all-zero link graph. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../charts/SankeyChart/sankey-data.test.ts | 23 +++++++++++++++++++ .../src/charts/SankeyChart/sankey-data.ts | 13 +++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 2755d240d7a9..7f795d53d7b7 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -74,6 +74,12 @@ describe('computeSankeyLayout', () => { expect(layoutOf({ links: [] }).nodes).toHaveLength(0) expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow('missing: nope') }) + + it('returns an empty layout when all link values are zero', () => { + const layout = layoutOf({ links: [{ source: 'start', target: 'a', value: 0 }] }) + expect(layout.nodes).toHaveLength(0) + expect(layout.total).toBe(0) + }) }) describe('sankeyHitAt', () => { @@ -96,4 +102,21 @@ describe('sankeyHitAt', () => { expect(sankeyHitAt(layout, { x: -50, y: -50 })).toBeNull() }) + + it('detects a zero-value node whose drawn height is floored at 1px', () => { + // A node with only empty inflow/outflow gets y1 === y0 from the layout engine. + // The draw code floors its height at Math.max(1, y1 - y0), so it paints as 1px. + // The hit box must match: floor the height here too. + const layoutWithZeroNode = layoutOf({ + nodes: [...NODES, { id: 'orphan' }], + links: LINKS, + }) + const zeroNode = layoutWithZeroNode.nodes.find((n) => n.id === 'orphan')! + // Even though the node has zero layout height, it can still be hit. + const hit = sankeyHitAt(layoutWithZeroNode, { + x: (zeroNode.x0 + zeroNode.x1) / 2, + y: zeroNode.y0, + }) + expect(hit).toEqual({ kind: 'node', index: layoutWithZeroNode.nodes.indexOf(zeroNode) }) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index d1ccff8bfa04..aa4068b7abdf 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -129,7 +129,8 @@ export const EMPTY_SANKEY_LAYOUT: SankeyChartLayout = { /** Lays the graph out inside `plot`. Pure: safe to call from a memo or a test. Throws when a link * names a missing node or the graph has a cycle, so a consumer bug surfaces through the chart's - * error boundary instead of drawing nothing. */ + * error boundary instead of drawing nothing. Returns `EMPTY_SANKEY_LAYOUT` when the graph has no + * flow (all link values are zero). */ export function computeSankeyLayout({ nodes, links, @@ -210,6 +211,11 @@ export function computeSankeyLayout({ const total = graph.nodes.filter((node) => node.targetLinks.length === 0).reduce((sum, node) => sum + node.value, 0) + // Guard against all-zero flow: the layout engine produces NaN coordinates when total is 0 + if (total === 0) { + return EMPTY_SANKEY_LAYOUT as SankeyChartLayout + } + return { nodes: outNodes, links: outLinks, columnCount, columnX, total, nodeWidth } } @@ -224,7 +230,10 @@ export function sankeyHitAt( ): SankeyHit | null { for (let i = 0; i < layout.nodes.length; i++) { const node = layout.nodes[i] - if (cursor.x >= node.x0 && cursor.x <= node.x1 && cursor.y >= node.y0 && cursor.y <= node.y1) { + const nodeHeight = Math.max(1, node.y1 - node.y0) + const y0 = node.y0 + const y1 = y0 + nodeHeight + if (cursor.x >= node.x0 && cursor.x <= node.x1 && cursor.y >= y0 && cursor.y <= y1) { return { kind: 'node', index: i } } } From 506820293da8e1a00002736f90b5795b5561c2e0 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:16:28 +0100 Subject: [PATCH 03/80] test(quill-charts): make the sankey zero-node hit test catch the regression The case probed at exactly y0. With y0 === y1 that point sits inside the raw box too, so the test passed with or without the 1px floor it exists to protect. Probe half a pixel in, where only the floored box reaches. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../charts/SankeyChart/sankey-data.test.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 7f795d53d7b7..7c307e158622 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -103,20 +103,13 @@ describe('sankeyHitAt', () => { expect(sankeyHitAt(layout, { x: -50, y: -50 })).toBeNull() }) - it('detects a zero-value node whose drawn height is floored at 1px', () => { - // A node with only empty inflow/outflow gets y1 === y0 from the layout engine. - // The draw code floors its height at Math.max(1, y1 - y0), so it paints as 1px. - // The hit box must match: floor the height here too. - const layoutWithZeroNode = layoutOf({ - nodes: [...NODES, { id: 'orphan' }], - links: LINKS, - }) - const zeroNode = layoutWithZeroNode.nodes.find((n) => n.id === 'orphan')! - // Even though the node has zero layout height, it can still be hit. - const hit = sankeyHitAt(layoutWithZeroNode, { - x: (zeroNode.x0 + zeroNode.x1) / 2, - y: zeroNode.y0, - }) - expect(hit).toEqual({ kind: 'node', index: layoutWithZeroNode.nodes.indexOf(zeroNode) }) + it('hits a zero-value node across the 1px the draw code floors it to', () => { + const withOrphan = layoutOf({ nodes: [...NODES, { id: 'orphan' }], links: LINKS }) + const orphan = withOrphan.nodes.find((n) => n.id === 'orphan')! + expect(orphan.y1 - orphan.y0).toBe(0) + + // Probe inside the floored band but outside the raw box, so dropping the floor fails here. + const hit = sankeyHitAt(withOrphan, { x: (orphan.x0 + orphan.x1) / 2, y: orphan.y0 + 0.5 }) + expect(hit).toEqual({ kind: 'node', index: withOrphan.nodes.indexOf(orphan) }) }) }) From 9b9f747526f85b41fc7718771031c4b85294fd3f Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:16:33 +0100 Subject: [PATCH 04/80] refactor(quill-charts): share the sankey value formatter SankeyChart and SankeyTooltip each carried their own identical default formatter. Export one from sankey-data and use it in both. Read the tooltip's title, value and color from a single branch on the hit kind rather than repeating the same discriminant in three ternaries. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../src/charts/SankeyChart/SankeyChart.tsx | 6 +----- .../src/charts/SankeyChart/SankeyTooltip.tsx | 18 ++++++++++-------- .../src/charts/SankeyChart/sankey-data.ts | 4 ++++ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 57562c7ca14d..e34265f75aca 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -14,7 +14,7 @@ import { Tooltip } from '../../overlays/Tooltip' import { drawSankey, drawSankeyHover } from './draw-sankey' import { SankeyLayoutContext } from './sankey-context' import type { SankeyLayoutContextValue } from './sankey-context' -import { computeSankeyLayout, hoverIndexToHit } from './sankey-data' +import { computeSankeyLayout, defaultValueFormatter, hoverIndexToHit } from './sankey-data' import type { SankeyChartLayout } from './sankey-data' import { SankeyColumnLabels } from './SankeyColumnLabels' import { SankeyNodeLabels } from './SankeyNodeLabels' @@ -33,10 +33,6 @@ const DEFAULT_LABEL_COLOR = 'rgba(0, 0, 0, 0.7)' const NO_SCALES: ChartScales = { x: () => undefined, y: () => 0, yTicks: () => [] } -function defaultValueFormatter(value: number): string { - return value.toLocaleString() -} - export function SankeyChart({ onError, ...rest diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx index 2802cb691ad7..2116137d9cfd 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx @@ -1,6 +1,7 @@ import React from 'react' import { TooltipSurface, TooltipSwatch } from '../../overlays/TooltipSurface' +import { defaultValueFormatter } from './sankey-data' import type { SankeyTooltipContext } from './types' export interface SankeyTooltipProps { @@ -8,10 +9,6 @@ export interface SankeyTooltipProps { valueFormatter?: (value: number) => React.ReactNode } -function defaultFormatter(value: number): string { - return value.toLocaleString() -} - function formatShare(fraction: number): string { return `${Math.round(fraction * 1000) / 10}%` } @@ -20,12 +17,17 @@ function formatShare(fraction: number): string { * share of the total inflow. */ export function SankeyTooltip({ ctx, - valueFormatter = defaultFormatter, + valueFormatter = defaultValueFormatter, }: SankeyTooltipProps): React.ReactElement { const { hit, total } = ctx - const title = hit.kind === 'node' ? hit.node.label : `${hit.link.source.label} → ${hit.link.target.label}` - const value = hit.kind === 'node' ? hit.node.value : hit.link.value - const color = hit.kind === 'node' ? hit.node.color : hit.link.color + const { title, value, color } = + hit.kind === 'node' + ? { title: hit.node.label, value: hit.node.value, color: hit.node.color } + : { + title: `${hit.link.source.label} → ${hit.link.target.label}`, + value: hit.link.value, + color: hit.link.color, + } const share = total > 0 ? value / total : 0 return ( diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index aa4068b7abdf..614202370277 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -118,6 +118,10 @@ const ALIGNMENTS = { justify: sankeyJustify, } +export function defaultValueFormatter(value: number): string { + return value.toLocaleString() +} + export const EMPTY_SANKEY_LAYOUT: SankeyChartLayout = { nodes: [], links: [], From f2666ebc871448dbbe1cbc41824e2528fe7812a9 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:20:34 +0100 Subject: [PATCH 05/80] fix(quill-charts): dim inactive sankey links against their resting color The hover layer repainted an inactive link at linkOpacity, so it composited over the static layer instead of replacing it and the link barely dimmed. Mix the dim against the color the link already rests at, then paint it opaque, which is what the comment above it already asked for and what the node path already does. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../packages/charts/src/charts/SankeyChart/draw-sankey.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index e5fcaab75404..60aed6a4ac07 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -85,7 +85,8 @@ export function drawSankeyHover( // layer and does not dim at all. for (const link of layout.links) { if (!activeLinks.has(link)) { - strokeLink(ctx, link, mixColors(link.color, dimTarget, dim), options.linkOpacity) + const resting = mixColors(dimTarget, link.color, options.linkOpacity) + strokeLink(ctx, link, mixColors(resting, dimTarget, dim), 1) } } for (const node of layout.nodes) { From 51f28d216310bb8057b318dd14bd96d789cdcc08 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:26:43 +0100 Subject: [PATCH 06/80] fix(mcp_analytics): fix journey sankey sizing and tooltip shares The wrapper div lacked display:flex, so the chart's flex-1 root never picked up its height and rendered nothing. The default tooltip also divided by the displayed paths' total instead of the cluster's full session count, and dropped which outcome a ribbon represents once two ribbons connect the same pair of nodes. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../clustering/ClusterJourneySankey.tsx | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index f53411b042ec..473c6345e137 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -1,7 +1,13 @@ import { useMemo } from 'react' -import { SankeyChart } from '@posthog/quill-charts' -import type { ChartTheme, SankeyChartConfig, SankeyLinkInput, SankeyNodeInput } from '@posthog/quill-charts' +import { SankeyChart, TooltipSurface, TooltipSwatch } from '@posthog/quill-charts' +import type { + ChartTheme, + SankeyChartConfig, + SankeyLinkInput, + SankeyNodeInput, + SankeyTooltipContext, +} from '@posthog/quill-charts' import { useChartTheme } from 'lib/charts/hooks' @@ -40,6 +46,51 @@ function nodeFill(kind: JourneyNodeKind, theme: ChartTheme): string { } } +function formatShare(fraction: number): string { + return `${Math.round(fraction * 1000) / 10}%` +} + +function outcomeLabel(outcome: JourneyLinkMeta['outcome'] | undefined): string | null { + if (!outcome) { + return null + } + return outcome === 'error' ? 'Error' : 'Completed' +} + +/** Shares are measured against every session in the cluster, not just the displayed top paths, + * so a subset of paths never reads as more than its real share. Link tooltips also name the + * outcome, because same-node pairs are split into a completed and an errored ribbon that would + * otherwise be indistinguishable except by color. */ +function makeJourneyTooltip(totalSessions: number) { + return function JourneyTooltip({ hit }: SankeyTooltipContext): JSX.Element { + const { title, value, color, outcome } = + hit.kind === 'node' + ? { title: hit.node.label, value: hit.node.value, color: hit.node.color, outcome: undefined } + : { + title: `${hit.link.source.label} → ${hit.link.target.label}`, + value: hit.link.value, + color: hit.link.color, + outcome: hit.link.meta?.outcome, + } + const share = totalSessions > 0 ? value / totalSessions : 0 + const outcomeText = outcomeLabel(outcome) + + return ( + +
+ + {title} +
+
+ {value.toLocaleString()} + {share > 0 ? ({formatShare(share)}) : null} +
+ {outcomeText ?
{outcomeText}
: null} +
+ ) + } +} + interface Props { journey: MCPIntentClusterJourneyApi | null | undefined } @@ -63,6 +114,8 @@ export function ClusterJourneySankey({ journey }: Props): JSX.Element | null { return { nodes, links } }, [journey, theme]) + const tooltip = useMemo(() => makeJourneyTooltip(journey?.total_sessions ?? 0), [journey?.total_sessions]) + if (!journey || !graph || graph.links.length === 0) { return (
@@ -77,12 +130,13 @@ export function ClusterJourneySankey({ journey }: Props): JSX.Element | null { {journey.total_sessions} session{journey.total_sessions === 1 ? '' : 's'} · top {journey.paths.length}{' '} path{journey.paths.length === 1 ? '' : 's'}
-
+
From daaa2cfdda906805ab8ee49dec1fc98d05ec5edf Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:27:03 +0100 Subject: [PATCH 07/80] fix(mcp_analytics): trim journey tooltip comment to one line Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../frontend/clustering/ClusterJourneySankey.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index 473c6345e137..f60fa157206c 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -57,10 +57,7 @@ function outcomeLabel(outcome: JourneyLinkMeta['outcome'] | undefined): string | return outcome === 'error' ? 'Error' : 'Completed' } -/** Shares are measured against every session in the cluster, not just the displayed top paths, - * so a subset of paths never reads as more than its real share. Link tooltips also name the - * outcome, because same-node pairs are split into a completed and an errored ribbon that would - * otherwise be indistinguishable except by color. */ +// Shares divide by every session in the cluster, not just the displayed top paths, so a subset never reads as 100%. function makeJourneyTooltip(totalSessions: number) { return function JourneyTooltip({ hit }: SankeyTooltipContext): JSX.Element { const { title, value, color, outcome } = From 0a268792c97aba81654a74a12573289580a201e6 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 00:36:25 +0100 Subject: [PATCH 08/80] fix(quill-charts): align sankey nodes to the right by distance from the sink Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../src/charts/SankeyChart/sankey-data.test.ts | 12 +++++++----- .../charts/src/charts/SankeyChart/sankey-layout.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 7c307e158622..b2fe97738c4d 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -48,13 +48,15 @@ describe('computeSankeyLayout', () => { expect(layout.total).toBe(40) }) - it('keeps a flow that ends early in its own column under left alignment', () => { + it('places a flow that ends early by alignment: justify, left, and right', () => { const links: SankeyLinkInput[] = [...LINKS, { source: 'start', target: 'ended', value: 5 }] const nodes: SankeyNodeInput[] = [...NODES, { id: 'ended', label: 'Ended' }] - const justified = layoutOf({ nodes, links }) - const left = layoutOf({ nodes, links, nodeAlign: 'left' }) - expect(justified.nodes.find((n) => n.id === 'ended')?.column).toBe(2) - expect(left.nodes.find((n) => n.id === 'ended')?.column).toBe(1) + const columnOf = (align: 'justify' | 'left' | 'right', id: string): number | undefined => + layoutOf({ nodes, links, nodeAlign: align }).nodes.find((n) => n.id === id)?.column + expect(columnOf('justify', 'ended')).toBe(2) + expect(columnOf('left', 'ended')).toBe(1) + // Right alignment counts from the sink, so the source stays first and the early end moves last. + expect([columnOf('right', 'start'), columnOf('right', 'a'), columnOf('right', 'ended')]).toEqual([0, 1, 2]) }) it('resolves node colors by label and defaults link color to the source node', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index 45a4506b6307..7ae36a8613e4 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -93,7 +93,7 @@ export function targetDepth(d: SankeyLink<{}, {}>): number { } export function sankeyRight(node: SankeyNode<{}, {}>, n: number): number { - return n - 1 - node.depth + return n - 1 - node.height } export function sankeyLeft(node: SankeyNode<{}, {}>): number { From 36b9ea3ab8d7628eaa9b3b4d550145bb87e1c92d Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 08:16:39 +0100 Subject: [PATCH 09/80] feat(quill-charts): sankey tooltip placement and touch taps Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- packages/quill/packages/charts/src/README.md | 2 +- .../charts/SankeyChart/SankeyChart.test.tsx | 20 +++++ .../src/charts/SankeyChart/SankeyChart.tsx | 6 +- .../charts/src/charts/SankeyChart/types.ts | 3 + .../SankeyChart/useSankeyInteraction.ts | 87 ++++++++++++++----- .../packages/charts/src/docs/chart-types.md | 2 + 6 files changed, 96 insertions(+), 24 deletions(-) diff --git a/packages/quill/packages/charts/src/README.md b/packages/quill/packages/charts/src/README.md index 08b24c0d0ad7..4c766163d27a 100644 --- a/packages/quill/packages/charts/src/README.md +++ b/packages/quill/packages/charts/src/README.md @@ -133,7 +133,7 @@ import { Sparkline } from '@posthog/quill-charts' ## More - Choosing a chart, the gotchas, and the docs index → [AGENTS.md](../AGENTS.md) -- Per-chart behavior (scatter, funnel, slope, pie, metric card) → [docs/chart-types.md](./docs/chart-types.md) +- Per-chart behavior (scatter, funnel, slope, pie, box plot, heatmap, sankey, sparkline, metric card) → [docs/chart-types.md](./docs/chart-types.md) - Axes, ranges, multi-axis, chrome → [docs/axes.md](./docs/axes.md) - Bars: layouts, per-bar overrides, `minBarSize`, `trackData` → [docs/bars.md](./docs/bars.md) - Tooltips → [docs/tooltips.md](./docs/tooltips.md); legend → [docs/legend.md](./docs/legend.md) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index fb616af25903..9d0a00267516 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -65,4 +65,24 @@ describe('SankeyChart', () => { fireEvent.click(chart.element) expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', value: 12 })) }) + + it('shows the tooltip on a first tap and fires onNodeClick on the second', async () => { + const onNodeClick = jest.fn() + const { chart } = renderHogChart( + , + { 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 down = Object.assign(new MouseEvent('pointerdown', { bubbles: true }), { pointerType: 'touch' }) + fireEvent(chart.element, down) + fireEvent.click(chart.element, nodeCenter('a')) + } + tap() + await waitFor(() => expect(getHogChartTooltip()?.textContent).toContain('Tool A')) + expect(onNodeClick).not.toHaveBeenCalled() + tap() + expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index e34265f75aca..b2ac9acef0fd 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -215,7 +215,11 @@ function SankeyChartInner({ ) : null} {children} {tooltipCtx && showTooltip ? ( - + ) : null} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index 01656c5913f4..aab6d2990152 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -39,6 +39,9 @@ export interface SankeyChartConfig { tooltip?: { /** Show the tooltip on hover. Defaults to true. */ enabled?: boolean + /** Where the tooltip sits, as on the cartesian charts. Defaults to `cursor`; `follow-data` + * anchors it to the hovered node's right edge or the ribbon's midpoint. */ + placement?: 'follow-data' | 'top' | 'cursor' } /** Per-side margin overrides. Should be referentially stable. */ margins?: Partial diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index ba5116a111d7..77fbd643f9a2 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useMemo, useRef } from 'react' import { useLatest } from '../../core/hooks/useLatest' import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' @@ -23,7 +23,8 @@ interface UseSankeyInteractionResult { handlers: { onMouseMove: (e: React.MouseEvent) => void onMouseLeave: () => void - onClick: () => void + onClick: (e: React.MouseEvent) => void + onPointerDown: (e: React.PointerEvent) => void } } @@ -104,6 +105,17 @@ export function useSankeyInteraction({ const hoverIndexRef = useLatest(hoverIndex) + const showHit = useCallback( + (hit: SankeyHit, cursor: { x: number; y: number }) => { + setHover(hitToHoverIndex(layoutRef.current, hit), cursor) + if (showTooltip) { + const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() + setTooltipCtx(buildTooltipCtx(layoutRef.current, hit, cursor, canvasBounds)) + } + }, + [layoutRef, showTooltip, setHover, setTooltipCtx, canvasRef] + ) + const onMouseMove = useCallback( (e: React.MouseEvent) => { const current = layoutRef.current @@ -117,33 +129,64 @@ export function useSankeyInteraction({ clearTooltip() return } - setHover(hitToHoverIndex(current, hit), cursor) - if (showTooltip) { - const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() - setTooltipCtx(buildTooltipCtx(current, hit, cursor, canvasBounds)) - } + showHit(hit, cursor) }, - [layoutRef, showTooltip, setHover, setTooltipCtx, clearTooltip, canvasRef] + [layoutRef, showHit, clearTooltip] ) const onMouseLeave = useCallback(() => { clearTooltip() }, [clearTooltip]) - const onClick = useCallback(() => { - const hit = hoverIndexToHit(layoutRef.current, hoverIndexRef.current) - if (!hit) { - return - } - const resolved = resolveHit(layoutRef.current, hit) - if (resolved.kind === 'node') { - onNodeClick?.(resolved.node) - } else { - onLinkClick?.(resolved.link) - } - }, [layoutRef, hoverIndexRef, onNodeClick, onLinkClick]) - - const handlers = useMemo(() => ({ onMouseMove, onMouseLeave, onClick }), [onMouseMove, onMouseLeave, onClick]) + // Touch devices fire no mousemove before a tap, so the click has to resolve what was tapped + // itself. As on the cartesian charts, the first tap on a node or ribbon shows its tooltip and + // only a tap on the element already showing one fires the click handler. Both refs are read + // at pointerdown because a tap's compatibility mouse events arrive after pointerup. + const lastPointerTypeRef = useRef('mouse') + const tapDownHoverIndexRef = useRef(-1) + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + lastPointerTypeRef.current = e.pointerType + tapDownHoverIndexRef.current = hoverIndexRef.current + }, + [hoverIndexRef] + ) + + const onClick = useCallback( + (e: React.MouseEvent) => { + const current = layoutRef.current + let hit = hoverIndexToHit(current, hoverIndexRef.current) + if (lastPointerTypeRef.current === 'touch') { + const rect = e.currentTarget.getBoundingClientRect() + const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } + hit = sankeyHitAt(current, cursor) + if (!hit) { + clearTooltip() + return + } + if (hitToHoverIndex(current, hit) !== tapDownHoverIndexRef.current) { + showHit(hit, cursor) + return + } + } + if (!hit) { + return + } + const resolved = resolveHit(current, hit) + if (resolved.kind === 'node') { + onNodeClick?.(resolved.node) + } else { + onLinkClick?.(resolved.link) + } + }, + [layoutRef, hoverIndexRef, clearTooltip, showHit, onNodeClick, onLinkClick] + ) + + const handlers = useMemo( + () => ({ onMouseMove, onMouseLeave, onClick, onPointerDown }), + [onMouseMove, onMouseLeave, onClick, onPointerDown] + ) // The lifecycle stores the base context; every value it holds was built by `buildTooltipCtx`, // so the extra Sankey fields are present. diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index ee8f2588b27b..ca54c71bd8dc 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -92,6 +92,8 @@ There is no `series` or `labels`. - Hovering a node lifts its ribbons and dims the rest of the graph; hovering a ribbon lifts just that ribbon. The default tooltip shows the node label or `source → target`, the value, and its share of `layout.total` (the summed inflow of the nodes with no incoming link). `onNodeClick` and `onLinkClick` receive the laid-out datum with its `meta`. + `tooltip.placement` takes the cartesian charts' values and defaults to `cursor`. + On touch, the first tap on a node or ribbon shows its tooltip and a second tap on the same one fires the click handler. - Custom overlays read `useSankeyLayout()` for the positioned `nodes`, `links`, `columnX`, and `total`. - The layout engine ships on its own as `sankeyLayout` (plus the `sankeyLeft` / `sankeyJustify` alignments and `sankeyLinkHorizontal`) for hosts that draw their own SVG. From e32c1dc11f5ee54c5ff1f64e60cf32df4e187102 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 26 Sep 2026 11:44:01 +0100 Subject: [PATCH 10/80] fix(quill-charts): fire sankey clicks when tooltips are disabled --- .../charts/SankeyChart/SankeyChart.test.tsx | 18 ++++++++++++++++++ .../charts/SankeyChart/useSankeyInteraction.ts | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 9d0a00267516..b760497ad455 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -85,4 +85,22 @@ describe('SankeyChart', () => { tap() expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) }) + + it('fires the click on the first tap when the tooltip is disabled', () => { + const onNodeClick = jest.fn() + const { chart } = renderHogChart( + + ) + const down = Object.assign(new MouseEvent('pointerdown', { bubbles: true }), { pointerType: 'touch' }) + fireEvent(chart.element, down) + fireEvent.click(chart.element, nodeCenter('a')) + + expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 77fbd643f9a2..0f13e843eb3a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -167,7 +167,9 @@ export function useSankeyInteraction({ } if (hitToHoverIndex(current, hit) !== tapDownHoverIndexRef.current) { showHit(hit, cursor) - return + if (showTooltip) { + return + } } } if (!hit) { @@ -180,7 +182,7 @@ export function useSankeyInteraction({ onLinkClick?.(resolved.link) } }, - [layoutRef, hoverIndexRef, clearTooltip, showHit, onNodeClick, onLinkClick] + [layoutRef, hoverIndexRef, clearTooltip, showHit, showTooltip, onNodeClick, onLinkClick] ) const handlers = useMemo( From 48bcbc6d089e85337f801eb7a281e2227f3af487 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 26 Sep 2026 21:57:08 +0100 Subject: [PATCH 11/80] refactor(mcp_analytics): extract journey tooltip --- .../clustering/ClusterJourneySankey.tsx | 44 +++-------------- .../frontend/clustering/JourneyTooltip.tsx | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 38 deletions(-) create mode 100644 products/mcp_analytics/frontend/clustering/JourneyTooltip.tsx diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index f60fa157206c..b77161652e7a 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' -import { SankeyChart, TooltipSurface, TooltipSwatch } from '@posthog/quill-charts' +import { SankeyChart } from '@posthog/quill-charts' import type { ChartTheme, SankeyChartConfig, @@ -20,6 +20,7 @@ import { type JourneyNodeKind, type JourneyNodeMeta, } from './clusterJourneyGraph' +import { JourneyTooltip } from './JourneyTooltip' import { ERROR_SERIES, PRIMARY_SERIES, seriesColor } from './seriesColors' // Flows that end early stay in their own stage column instead of being pushed to the outcome. @@ -46,45 +47,12 @@ function nodeFill(kind: JourneyNodeKind, theme: ChartTheme): string { } } -function formatShare(fraction: number): string { - return `${Math.round(fraction * 1000) / 10}%` -} - -function outcomeLabel(outcome: JourneyLinkMeta['outcome'] | undefined): string | null { - if (!outcome) { - return null - } - return outcome === 'error' ? 'Error' : 'Completed' -} - // Shares divide by every session in the cluster, not just the displayed top paths, so a subset never reads as 100%. function makeJourneyTooltip(totalSessions: number) { - return function JourneyTooltip({ hit }: SankeyTooltipContext): JSX.Element { - const { title, value, color, outcome } = - hit.kind === 'node' - ? { title: hit.node.label, value: hit.node.value, color: hit.node.color, outcome: undefined } - : { - title: `${hit.link.source.label} → ${hit.link.target.label}`, - value: hit.link.value, - color: hit.link.color, - outcome: hit.link.meta?.outcome, - } - const share = totalSessions > 0 ? value / totalSessions : 0 - const outcomeText = outcomeLabel(outcome) - - return ( - -
- - {title} -
-
- {value.toLocaleString()} - {share > 0 ? ({formatShare(share)}) : null} -
- {outcomeText ?
{outcomeText}
: null} -
- ) + return function JourneyTooltipAdapter({ + hit, + }: SankeyTooltipContext): JSX.Element { + return } } diff --git a/products/mcp_analytics/frontend/clustering/JourneyTooltip.tsx b/products/mcp_analytics/frontend/clustering/JourneyTooltip.tsx new file mode 100644 index 000000000000..3b4509828c0e --- /dev/null +++ b/products/mcp_analytics/frontend/clustering/JourneyTooltip.tsx @@ -0,0 +1,48 @@ +import { TooltipSurface, TooltipSwatch } from '@posthog/quill-charts' +import type { SankeyTooltipContext } from '@posthog/quill-charts' + +import type { JourneyLinkMeta, JourneyNodeMeta } from './clusterJourneyGraph' + +interface JourneyTooltipProps { + hit: SankeyTooltipContext['hit'] + totalSessions: number +} + +function formatShare(fraction: number): string { + return `${Math.round(fraction * 1000) / 10}%` +} + +function outcomeLabel(outcome: JourneyLinkMeta['outcome'] | undefined): string | null { + if (!outcome) { + return null + } + return outcome === 'error' ? 'Error' : 'Completed' +} + +export function JourneyTooltip({ hit, totalSessions }: JourneyTooltipProps): JSX.Element { + const { title, value, color, outcome } = + hit.kind === 'node' + ? { title: hit.node.label, value: hit.node.value, color: hit.node.color, outcome: undefined } + : { + title: `${hit.link.source.label} → ${hit.link.target.label}`, + value: hit.link.value, + color: hit.link.color, + outcome: hit.link.meta?.outcome, + } + const share = totalSessions > 0 ? value / totalSessions : 0 + const outcomeText = outcomeLabel(outcome) + + return ( + +
+ + {title} +
+
+ {value.toLocaleString()} + {share > 0 ? ({formatShare(share)}) : null} +
+ {outcomeText ?
{outcomeText}
: null} +
+ ) +} From f08e6ba85fa8f787a38b5ea5441ef53f70cf417a Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 27 Sep 2026 20:39:37 +0100 Subject: [PATCH 12/80] fix(quill-charts): clear stale Sankey hover and separate journey nodes --- .../charts/SankeyChart/SankeyChart.test.tsx | 16 ++++++++- .../SankeyChart/useSankeyInteraction.ts | 21 ++++++----- .../packages/charts/src/docs/chart-types.md | 2 +- .../clustering/clusterJourneyGraph.test.ts | 35 +++++++++++++------ .../clustering/clusterJourneyGraph.ts | 4 +-- 5 files changed, 56 insertions(+), 22 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index b760497ad455..a983c07762c2 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -52,7 +52,7 @@ describe('SankeyChart', () => { it('shows the hovered node in the tooltip and fires onNodeClick for it', async () => { const onNodeClick = jest.fn() - const { chart } = renderHogChart( + const { chart, rerender } = renderHogChart( , { nativeTooltip: true } ) @@ -64,6 +64,20 @@ describe('SankeyChart', () => { expect(getHogChartTooltip()?.textContent).toContain('100%') fireEvent.click(chart.element) expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', value: 12 })) + + onNodeClick.mockClear() + rerender( + + ) + await waitFor(() => expect(getHogChartTooltip()).toBeNull()) + fireEvent.click(chart.element) + expect(onNodeClick).not.toHaveBeenCalled() }) it('shows the tooltip on a first tap and fires onNodeClick on the second', async () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 0f13e843eb3a..0604c2bdf7a0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { useLatest } from '../../core/hooks/useLatest' import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' @@ -95,13 +95,18 @@ export function useSankeyInteraction({ [layoutRef, canvasRef] ) - const { hoverIndex, hoverPosition, tooltipCtx, setHover, setTooltipCtx, clearTooltip } = useTooltipLifecycle< - NodeMeta | LinkMeta - >({ - wrapperRef, - rebuildPinnedCtx, - rebuildDeps: [layout], - }) + const { hoverIndex, hoverPosition, tooltipCtx, isPinned, setHover, setTooltipCtx, clearTooltip } = + useTooltipLifecycle({ + wrapperRef, + rebuildPinnedCtx, + rebuildDeps: [layout], + }) + + useEffect(() => { + if (!isPinned) { + clearTooltip() + } + }, [layout, isPinned, clearTooltip]) const hoverIndexRef = useLatest(hoverIndex) diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index ca54c71bd8dc..5c58f01dc24b 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -90,7 +90,7 @@ There is no `series` or `labels`. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. `showNodeValues` appends the node value. - Hovering a node lifts its ribbons and dims the rest of the graph; hovering a ribbon lifts just that ribbon. - The default tooltip shows the node label or `source → target`, the value, and its share of `layout.total` (the summed inflow of the nodes with no incoming link). + The default tooltip shows the node label or `source → target`, the value, and its share of `layout.total` (the summed value of source nodes with no incoming link). `onNodeClick` and `onLinkClick` receive the laid-out datum with its `meta`. `tooltip.placement` takes the cartesian charts' values and defaults to `cursor`. On touch, the first tap on a node or ribbon shows its tooltip and a second tap on the same one fires the click handler. diff --git a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts index bbff0729c4f6..1d1091f8dd6d 100644 --- a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts +++ b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts @@ -12,21 +12,36 @@ describe('buildJourneyGraph', () => { const graph = buildJourneyGraph(PATHS) // The same tool at two stages is two nodes; Ended repeats per column it fills. expect(graph.nodes.map((n) => n.id)).toEqual([ - '0::Init', - '1::schema', - '2::sql', - '3::Ended', - '4::Completed', - '4::Error', - '1::sql', - '2::Ended', + '0::init::Init', + '1::tool::schema', + '2::tool::sql', + '3::ended::Ended', + '4::completed::Completed', + '4::error::Error', + '1::tool::sql', + '2::ended::Ended', ]) - const schemaToSql = graph.links.filter((l) => l.source === '1::schema' && l.target === '2::sql') + const schemaToSql = graph.links.filter((l) => l.source === '1::tool::schema' && l.target === '2::tool::sql') expect(schemaToSql.map((l) => [l.meta?.outcome, l.value])).toEqual([ ['completed', 20], ['error', 5], ]) - const intoError = graph.links.filter((l) => l.target === '4::Error') + const intoError = graph.links.filter((l) => l.target === '4::error::Error') expect(intoError.reduce((sum, l) => sum + l.value, 0)).toBe(8) }) + + it('keeps a tool named Ended separate from a missing step', () => { + const graph = buildJourneyGraph([ + { steps: ['Ended', null], outcome: 'completed', count: 2 }, + { steps: [null, null], outcome: 'completed', count: 3 }, + ]) + + const firstStage = graph.nodes.filter((node) => node.label === 'Ended' && node.id.startsWith('1::')) + expect(firstStage.map((node) => [node.meta?.kind, node.id])).toEqual([ + ['tool', '1::tool::Ended'], + ['ended', '1::ended::Ended'], + ]) + expect(graph.links.filter((link) => link.target === '1::tool::Ended').map((link) => link.value)).toEqual([2]) + expect(graph.links.filter((link) => link.target === '1::ended::Ended').map((link) => link.value)).toEqual([3]) + }) }) diff --git a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts index 86ce1cea15db..fb6ec5fb93de 100644 --- a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts +++ b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts @@ -28,7 +28,7 @@ function outcomeOf(path: MCPIntentClusterJourneyPathApi): JourneyOutcome { /** * Turns the top paths of a cluster into a stage-per-column flow graph. Every path spans the same * number of columns: Init, one column per step (a step that never happened is `Ended`), then the - * outcome. Node ids carry the column so the same tool at two stages is two nodes, and links are + * outcome. Node ids carry the column and kind so the same tool at two stages is two nodes, and links are * split by outcome so error flows keep their own ribbon. */ export function buildJourneyGraph(paths: readonly MCPIntentClusterJourneyPathApi[]): JourneyGraph { @@ -36,7 +36,7 @@ export function buildJourneyGraph(paths: readonly MCPIntentClusterJourneyPathApi const links = new Map>() const nodeId = (column: number, label: string, kind: JourneyNodeKind): string => { - const id = `${column}::${label}` + const id = `${column}::${kind}::${label}` if (!nodes.has(id)) { nodes.set(id, { id, label, meta: { kind } }) } From 8ccbe4802aa5f362a69b4fa2382172f714b9b267 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 27 Sep 2026 20:45:17 +0100 Subject: [PATCH 13/80] perf(quill-charts): skip distant sankey hit checks --- .../packages/charts/src/charts/SankeyChart/sankey-data.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 614202370277..e57ab8ec0cf7 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -249,10 +249,14 @@ export function sankeyHitAt( if (cursor.x < startX || cursor.x > endX || endX <= startX) { continue } + const halfWidth = Math.max(link.width, 1) / 2 + if (cursor.y < Math.min(link.y0, link.y1) - halfWidth || cursor.y > Math.max(link.y0, link.y1) + halfWidth) { + continue + } const t = solveBezierT(startX, endX, cursor.x) const centerY = bezierY(link.y0, link.y1, t) const distance = Math.abs(cursor.y - centerY) - if (distance <= Math.max(link.width, 1) / 2 && (!best || distance < best.distance)) { + if (distance <= halfWidth && (!best || distance < best.distance)) { best = { index: i, distance } } } From 6402cbb44d9b792ef0c71dd77407949097bcecc8 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 28 Sep 2026 19:36:57 +0100 Subject: [PATCH 14/80] fix(quill-charts): harden sankey layout inputs --- packages/quill/packages/charts/package.json | 2 +- .../charts/SankeyChart/SankeyColumnLabels.tsx | 5 +++-- .../charts/SankeyChart/SankeyNodeLabels.tsx | 5 +++-- .../src/charts/SankeyChart/sankey-data.ts | 21 ++++++++++++++++--- .../src/charts/SankeyChart/sankey-layout.ts | 4 ++-- .../SankeyChart/useSankeyInteraction.ts | 6 +++--- .../packages/charts/src/docs/chart-types.md | 2 +- pnpm-lock.yaml | 6 +++--- 8 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/quill/packages/charts/package.json b/packages/quill/packages/charts/package.json index 411dedfcac78..09cd57e7c512 100644 --- a/packages/quill/packages/charts/package.json +++ b/packages/quill/packages/charts/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@floating-ui/react": "^0.27.19", + "@types/d3-shape": "^3.1.6", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", @@ -48,7 +49,6 @@ "@types/d3-array": "^3.2.1", "@types/d3-color": "^3.1.3", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^3.1.6", "@types/jest": "catalog:", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx index 95fb09f485ba..5d3474ea6662 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx @@ -26,9 +26,10 @@ export function SankeyColumnLabels({ labels, color }: SankeyColumnLabelsProps): const lastColumn = layout.columnCount - 1 return ( <> - {layout.columnX.map((x, column) => { + {Array.from({ length: layout.columnCount }, (_, column) => { + const x = layout.columnX[column] const label = labels[column] - if (!label) { + if (!label || x === undefined) { return null } let placement: React.CSSProperties diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx index c198ecab6830..c7ab395f3081 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -26,8 +26,9 @@ export interface SankeyNodeLabelsProps { * inside the plot. Labels truncate to the free space before the next column. */ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNodeLabelsProps): React.ReactElement { const { layout } = useSankeyLayout() - const gap = layout.columnCount > 1 ? layout.columnX[1] - layout.columnX[0] - layout.nodeWidth : Infinity - const maxWidth = gap - LABEL_GAP * 2 + const occupiedColumns = layout.columnX.filter((x): x is number => Number.isFinite(x)) + const gap = occupiedColumns.length > 1 ? occupiedColumns[1] - occupiedColumns[0] - layout.nodeWidth : Infinity + const maxWidth = Math.max(0, gap - LABEL_GAP * 2) return ( <> diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index e57ab8ec0cf7..31ee5279c92c 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -150,6 +150,21 @@ export function computeSankeyLayout({ return EMPTY_SANKEY_LAYOUT as SankeyChartLayout } + const nodeIds = new Set() + for (const node of nodes) { + if (nodeIds.has(node.id)) throw new Error(`duplicate Sankey node id: ${node.id}`) + nodeIds.add(node.id) + } + for (const link of links) { + if (!Number.isFinite(link.value) || link.value < 0) { + throw new Error(`Sankey link value must be a finite non-negative number: ${link.value}`) + } + if (!nodeIds.has(link.source) || !nodeIds.has(link.target)) { + throw new Error(`Sankey link refers to a missing node: ${link.source} -> ${link.target}`) + } + } + const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, nodes.length - 1)) + // The engine mutates its inputs, so hand it fresh objects. const engineNodes: LayoutNodeProps[] = nodes.map((node) => { const label = node.label ?? node.id @@ -167,7 +182,7 @@ export function computeSankeyLayout({ .nodeId((node) => node.id) .nodeAlign(ALIGNMENTS[nodeAlign]) .nodeSort(preserveNodeOrder ? null : undefined) - .nodeWidth(nodeWidth) + .nodeWidth(effectiveNodeWidth) .nodePadding(nodePadding) .extent([ [plot.plotLeft, plot.plotTop], @@ -210,7 +225,7 @@ export function computeSankeyLayout({ const columnCount = Math.max(0, ...graph.nodes.map((node) => node.layer + 1)) const columnX: number[] = [] for (const node of graph.nodes) { - columnX[node.layer] = node.x0 + columnX[node.layer] = Math.min(columnX[node.layer] ?? Infinity, node.x0) } const total = graph.nodes.filter((node) => node.targetLinks.length === 0).reduce((sum, node) => sum + node.value, 0) @@ -220,7 +235,7 @@ export function computeSankeyLayout({ return EMPTY_SANKEY_LAYOUT as SankeyChartLayout } - return { nodes: outNodes, links: outLinks, columnCount, columnX, total, nodeWidth } + return { nodes: outNodes, links: outLinks, columnCount, columnX, total, nodeWidth: effectiveNodeWidth } } export type SankeyHit = { kind: 'node'; index: number } | { kind: 'link'; index: number } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index 7ae36a8613e4..016ed227cab4 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -191,8 +191,8 @@ function computeLinkBreadths(): SankeyLayout { let x0 = 0, y0 = 0, diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 0604c2bdf7a0..d7cfea1c0ac2 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -161,10 +161,10 @@ export function useSankeyInteraction({ const onClick = useCallback( (e: React.MouseEvent) => { const current = layoutRef.current - let hit = hoverIndexToHit(current, hoverIndexRef.current) + const rect = e.currentTarget.getBoundingClientRect() + const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } + let hit = sankeyHitAt(current, cursor) ?? hoverIndexToHit(current, hoverIndexRef.current) if (lastPointerTypeRef.current === 'touch') { - const rect = e.currentTarget.getBoundingClientRect() - const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } hit = sankeyHitAt(current, cursor) if (!hit) { clearTooltip() diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 5c58f01dc24b..948ec967813d 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -79,7 +79,7 @@ A single-cell tooltip resolves from the cursor, `onCellClick` reports `{ xIndex, Flow between stages: `nodes: { id, label?, color?, meta? }[]` and `links: { source, target, value, color?, meta? }[]`, where `source` and `target` are node ids. There is no `series` or `labels`. -- The graph must be acyclic and every link must name existing nodes; the layout throws otherwise, and the chart's error boundary reports it through `onError`. +- Node ids must be unique. The graph must be acyclic, every link must name existing nodes, and link values must be finite and non-negative; the layout throws otherwise, and the chart's error boundary reports it through `onError`. A node that recurs at several stages (the same tool called twice) needs one id per stage, so prefix ids with the stage. - Nodes that share a `label` share a palette color, so a repeated tool keeps one hue across columns. A link without `color` takes its source node's color. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63e91196fb98..3d0764e79e8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2079,6 +2079,9 @@ importers: '@floating-ui/react': specifier: ^0.27.19 version: 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/d3-shape': + specifier: ^3.1.6 + version: 3.1.8 d3-array: specifier: ^3.2.4 version: 3.2.4 @@ -2125,9 +2128,6 @@ importers: '@types/d3-scale': specifier: ^4.0.8 version: 4.0.9 - '@types/d3-shape': - specifier: ^3.1.6 - version: 3.1.8 '@types/jest': specifier: 'catalog:' version: 29.5.14 From 181c149427f0914325cfb32acfbb2cfab639821d Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 28 Sep 2026 20:45:29 +0100 Subject: [PATCH 15/80] fix(quill-charts): reset sankey errors on data changes --- .../packages/charts/src/charts/SankeyChart/SankeyChart.tsx | 5 ++++- .../quill/packages/charts/src/core/ChartErrorBoundary.tsx | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index b2ac9acef0fd..ed9899788f45 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -38,7 +38,10 @@ export function SankeyChart({ ...rest }: SankeyChartProps): React.ReactElement { return ( - + node.id).join(',')}`} + > ) diff --git a/packages/quill/packages/charts/src/core/ChartErrorBoundary.tsx b/packages/quill/packages/charts/src/core/ChartErrorBoundary.tsx index 02d9e6c93be9..95f104647947 100644 --- a/packages/quill/packages/charts/src/core/ChartErrorBoundary.tsx +++ b/packages/quill/packages/charts/src/core/ChartErrorBoundary.tsx @@ -4,6 +4,7 @@ interface ChartErrorBoundaryProps { children: React.ReactNode fallback?: React.ReactNode onError?: (error: Error, info: React.ErrorInfo) => void + resetKey?: unknown } interface ChartErrorBoundaryState { @@ -33,6 +34,12 @@ export class ChartErrorBoundary extends React.Component Date: Mon, 28 Sep 2026 20:46:55 +0100 Subject: [PATCH 16/80] fix(quill-charts): match sankey hit testing to ribbons --- .../src/charts/SankeyChart/sankey-data.ts | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 31ee5279c92c..4ce15faf75ea 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -268,9 +268,7 @@ export function sankeyHitAt( if (cursor.y < Math.min(link.y0, link.y1) - halfWidth || cursor.y > Math.max(link.y0, link.y1) + halfWidth) { continue } - const t = solveBezierT(startX, endX, cursor.x) - const centerY = bezierY(link.y0, link.y1, t) - const distance = Math.abs(cursor.y - centerY) + const distance = bezierDistance(startX, endX, link.y0, link.y1, cursor) if (distance <= halfWidth && (!best || distance < best.distance)) { best = { index: i, distance } } @@ -278,6 +276,16 @@ export function sankeyHitAt( return best ? { kind: 'link', index: best.index } : null } +function bezierDistance(x0: number, x1: number, y0: number, y1: number, cursor: { x: number; y: number }): number { + let best = Infinity + for (let step = 0; step <= 24; step++) { + const t = step / 24 + const distance = Math.hypot(bezierX(x0, x1, t) - cursor.x, bezierY(y0, y1, t) - cursor.y) + best = Math.min(best, distance) + } + return best +} + /** x(t) of the ribbon centerline, with both control points at the horizontal midpoint. */ function bezierX(x0: number, x1: number, t: number): number { const xm = (x0 + x1) / 2 @@ -292,21 +300,6 @@ export function bezierY(y0: number, y1: number, t: number): number { return u * u * u * y0 + 3 * u * u * t * y0 + 3 * u * t * t * y1 + t * t * t * y1 } -/** Inverts `bezierX` for a cursor x. `x(t)` is monotonic on [0, 1], so bisection converges. */ -function solveBezierT(x0: number, x1: number, x: number): number { - let lo = 0 - let hi = 1 - for (let i = 0; i < 24; i++) { - const mid = (lo + hi) / 2 - if (bezierX(x0, x1, mid) < x) { - lo = mid - } else { - hi = mid - } - } - return (lo + hi) / 2 -} - /** Encodes a hit into the single `hoverIndex` the shared draw loop expects: nodes first, then * links offset by the node count. */ export function hitToHoverIndex(layout: SankeyChartLayout, hit: SankeyHit | null): number { From 93e271e7edf3ec5ba8545fcb89e26c64d30d0e61 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 29 Sep 2026 09:57:16 +0100 Subject: [PATCH 17/80] fix(quill-charts): match the missing sankey node error in its test The layout error now names both ends of the link, so the test expects the source and target. Generated-By: PostHog Desktop Task-Id: c7935e32-37c2-4454-bfe9-7edb68c75572 --- .../packages/charts/src/charts/SankeyChart/sankey-data.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index b2fe97738c4d..97480ff3bfd2 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -74,7 +74,7 @@ describe('computeSankeyLayout', () => { it('returns an empty layout without links and throws on a link to a missing node', () => { expect(layoutOf({ links: [] }).nodes).toHaveLength(0) - expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow('missing: nope') + expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow('missing node: start -> nope') }) it('returns an empty layout when all link values are zero', () => { From 9ccc49bd81eb0e7e5f55a2ab84ea261b1b4dbce1 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:05:26 +0100 Subject: [PATCH 18/80] fix(quill-charts): brace the duplicate sankey node id guard Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/sankey-data.test.ts | 4 +++- .../packages/charts/src/charts/SankeyChart/sankey-data.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 97480ff3bfd2..82ebaf8a7ce9 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -74,7 +74,9 @@ describe('computeSankeyLayout', () => { it('returns an empty layout without links and throws on a link to a missing node', () => { expect(layoutOf({ links: [] }).nodes).toHaveLength(0) - expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow('missing node: start -> nope') + expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow( + 'missing node: start -> nope' + ) }) it('returns an empty layout when all link values are zero', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 4ce15faf75ea..451107b1b39a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -152,7 +152,9 @@ export function computeSankeyLayout({ const nodeIds = new Set() for (const node of nodes) { - if (nodeIds.has(node.id)) throw new Error(`duplicate Sankey node id: ${node.id}`) + if (nodeIds.has(node.id)) { + throw new Error(`duplicate Sankey node id: ${node.id}`) + } nodeIds.add(node.id) } for (const link of links) { From da584b6b6a81888e443e06172020efc852880953 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:26:59 +0100 Subject: [PATCH 19/80] fix(quill-charts): sample sankey ribbons densely enough to hit thin ones Pick the curve sample count from the ribbon's span so adjacent samples sit within 1px, and position every column, including one with no node. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/sankey-data.test.ts | 20 ++++++++++++++++- .../src/charts/SankeyChart/sankey-data.ts | 22 +++++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 82ebaf8a7ce9..b028d834f3c1 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -1,4 +1,4 @@ -import { computeSankeyLayout, sankeyHitAt } from './sankey-data' +import { bezierX, bezierY, computeSankeyLayout, sankeyHitAt } from './sankey-data' import type { ComputeSankeyLayoutOptions, SankeyLinkInput, SankeyNodeInput } from './sankey-data' const PLOT = { plotLeft: 0, plotTop: 0, plotWidth: 600, plotHeight: 300 } @@ -107,6 +107,24 @@ describe('sankeyHitAt', () => { expect(sankeyHitAt(layout, { x: -50, y: -50 })).toBeNull() }) + it('hits a long, thin ribbon between coarse curve samples', () => { + const thin = layoutOf({ + nodes: [{ id: 'start' }, { id: 'a' }, { id: 'b' }], + links: [ + { source: 'start', target: 'a', value: 1000 }, + { source: 'start', target: 'b', value: 1 }, + ], + }) + const index = thin.links.findIndex((l) => l.target.id === 'b') + const link = thin.links[index] + expect(link.width).toBeLessThan(1) + + // Halfway between two of 24 evenly spaced samples, where a fixed sample count misses by ~18px. + const t = 0.5 + 1 / 48 + const cursor = { x: bezierX(link.source.x1, link.target.x0, t), y: bezierY(link.y0, link.y1, t) } + expect(sankeyHitAt(thin, cursor)).toEqual({ kind: 'link', index }) + }) + it('hits a zero-value node across the 1px the draw code floors it to', () => { const withOrphan = layoutOf({ nodes: [...NODES, { id: 'orphan' }], links: LINKS }) const orphan = withOrphan.nodes.find((n) => n.id === 'orphan')! diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 451107b1b39a..4fa57ec690fb 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -225,10 +225,9 @@ export function computeSankeyLayout({ }) const columnCount = Math.max(0, ...graph.nodes.map((node) => node.layer + 1)) - const columnX: number[] = [] - for (const node of graph.nodes) { - columnX[node.layer] = Math.min(columnX[node.layer] ?? Infinity, node.x0) - } + // Mirrors the engine's column spacing, so a column with no node still gets a position. + const columnStep = columnCount <= 1 ? 0 : (plot.plotWidth - effectiveNodeWidth) / (columnCount - 1) + const columnX = Array.from({ length: columnCount }, (_, column) => plot.plotLeft + column * columnStep) const total = graph.nodes.filter((node) => node.targetLinks.length === 0).reduce((sum, node) => sum + node.value, 0) @@ -278,10 +277,19 @@ export function sankeyHitAt( return best ? { kind: 'link', index: best.index } : null } +const MIN_CURVE_SAMPLES = 24 +const MAX_CURVE_SAMPLES = 4096 + function bezierDistance(x0: number, x1: number, y0: number, y1: number, cursor: { x: number; y: number }): number { + // Each coordinate moves at most 1.5x its span per unit of t, so this count keeps adjacent + // samples within 1px and a thin ribbon cannot fall between them. + const samples = Math.min( + MAX_CURVE_SAMPLES, + Math.max(MIN_CURVE_SAMPLES, Math.ceil(1.5 * (Math.abs(x1 - x0) + Math.abs(y1 - y0)))) + ) let best = Infinity - for (let step = 0; step <= 24; step++) { - const t = step / 24 + for (let step = 0; step <= samples; step++) { + const t = step / samples const distance = Math.hypot(bezierX(x0, x1, t) - cursor.x, bezierY(y0, y1, t) - cursor.y) best = Math.min(best, distance) } @@ -289,7 +297,7 @@ function bezierDistance(x0: number, x1: number, y0: number, y1: number, cursor: } /** x(t) of the ribbon centerline, with both control points at the horizontal midpoint. */ -function bezierX(x0: number, x1: number, t: number): number { +export function bezierX(x0: number, x1: number, t: number): number { const xm = (x0 + x1) / 2 const u = 1 - t return u * u * u * x0 + 3 * u * u * t * xm + 3 * u * t * t * xm + t * t * t * x1 From 10c42619ad0e18a4fcf9f29d2d604c48034af670 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:29:04 +0100 Subject: [PATCH 20/80] fix(quill-charts): address sankey review feedback - render the shared PieTooltip instead of a Sankey copy of it - clear hidden tooltip content when tooltips are disabled - reset the error boundary when any link changes - resolve the hovered flow once per item, not on every fade frame - list SankeyChart among the charts that resolve var() colors - show the journey empty state when every link has zero flow Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/quill/packages/charts/AGENTS.md | 2 +- .../charts/SankeyChart/SankeyChart.test.tsx | 16 +++++- .../src/charts/SankeyChart/SankeyChart.tsx | 36 ++++++++---- .../src/charts/SankeyChart/SankeyTooltip.tsx | 45 --------------- .../src/charts/SankeyChart/draw-sankey.ts | 56 ++++++++++++------- .../SankeyChart/useSankeyInteraction.ts | 18 ++++-- .../quill/packages/charts/src/core/types.ts | 2 +- packages/quill/packages/charts/src/index.ts | 2 - .../clustering/ClusterJourneySankey.tsx | 2 +- 9 files changed, 93 insertions(+), 86 deletions(-) delete mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx diff --git a/packages/quill/packages/charts/AGENTS.md b/packages/quill/packages/charts/AGENTS.md index 5929be80ccc8..d39799396e91 100644 --- a/packages/quill/packages/charts/AGENTS.md +++ b/packages/quill/packages/charts/AGENTS.md @@ -36,7 +36,7 @@ const theme = useChartTheme() // reads CSS vars, tracks light/dark switches ``` - Series colors come from `--data-color-1..15`; chrome from `--color-graph-axis-label` / `--color-graph-axis-line` / `--color-graph-crosshair`. `themeFromCssVars()` is the one-shot version; `DEFAULT_CHART_COLORS` is the no-DOM fallback. -- Omit `color` on a series to get palette assignment by index (preferred). An explicit `color` must be a concrete color (hex, rgb): line, area, and bar series hand it to the canvas unresolved, so resolve `var(--...)` in the host first. Only `Heatmap` and `ScatterChart` resolve `var()` themselves. +- Omit `color` on a series to get palette assignment by index (preferred). An explicit `color` must be a concrete color (hex, rgb): line, area, and bar series hand it to the canvas unresolved, so resolve `var(--...)` in the host first. Only `Heatmap`, `ScatterChart`, and `SankeyChart` (node and link colors) resolve `var()` themselves. - The theme helpers carry the default chrome (faint dashed grid, stronger axis line, dashed crosshair), and `DEFAULT_CHART_CONFIG` carries the matching switches. Consumers opt out field by field (`showGrid: false`). Details: [docs/axes.md](./src/docs/axes.md). - `theme.skipDraw` mounts the canvas without painting, for deterministic visual snapshots. - The static canvas carries `data-hog-charts-paint`: `pending` from a wipe or an input change until the next paint, then `done`. The Storybook runner waits for no `pending` canvas before a snapshot. diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index a983c07762c2..b4933a36972e 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, waitFor } from '@testing-library/react' +import { fireEvent, render, waitFor } from '@testing-library/react' import type { ChartTheme } from '../../core/types' import { getHogChartTooltip, renderHogChart } from '../../testing' @@ -117,4 +117,18 @@ describe('SankeyChart', () => { expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) }) + + it('recovers from an invalid graph when only a link endpoint is corrected', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const onError = jest.fn() + const broken: SankeyLinkInput[] = [LINKS[0], { source: 'a', target: 'missing', value: 12 }] + const { container, rerender } = render( + + ) + expect(onError).toHaveBeenCalled() + expect(container.textContent).toContain('Something went wrong') + + rerender() + expect(container.textContent).not.toContain('Something went wrong') + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index ed9899788f45..95b72515b034 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -11,14 +11,15 @@ import { applyMarginOverride } from '../../core/hooks/useChartMargins' import type { ChartDrawArgs, ChartMargins, ChartScales } from '../../core/types' import { defaultResolveValue } from '../../core/types' import { Tooltip } from '../../overlays/Tooltip' -import { drawSankey, drawSankeyHover } from './draw-sankey' +import { PieTooltip } from '../PieChart/PieTooltip' +import { drawSankey, drawSankeyHover, sankeyActiveFlow } from './draw-sankey' +import type { SankeyActiveFlow } from './draw-sankey' import { SankeyLayoutContext } from './sankey-context' import type { SankeyLayoutContextValue } from './sankey-context' import { computeSankeyLayout, defaultValueFormatter, hoverIndexToHit } from './sankey-data' -import type { SankeyChartLayout } from './sankey-data' +import type { SankeyChartLayout, SankeyLinkInput, SankeyNodeInput } from './sankey-data' import { SankeyColumnLabels } from './SankeyColumnLabels' import { SankeyNodeLabels } from './SankeyNodeLabels' -import { SankeyTooltip } from './SankeyTooltip' import type { SankeyChartProps, SankeyTooltipContext } from './types' import { useSankeyInteraction } from './useSankeyInteraction' @@ -33,15 +34,19 @@ const DEFAULT_LABEL_COLOR = 'rgba(0, 0, 0, 0.7)' const NO_SCALES: ChartScales = { x: () => undefined, y: () => 0, yTicks: () => [] } +/** Changes whenever the layout could validate differently, so a corrected graph clears the error. */ +function graphKey(nodes: SankeyNodeInput[], links: SankeyLinkInput[]): string { + const nodeIds = nodes.map((node) => node.id).join(',') + const linkKeys = links.map((link) => `${link.source}>${link.target}=${link.value}`).join(',') + return `${nodeIds}|${linkKeys}` +} + export function SankeyChart({ onError, ...rest }: SankeyChartProps): React.ReactElement { return ( - node.id).join(',')}`} - > + ) @@ -132,14 +137,25 @@ function SankeyChartInner({ drawSankey(drawCtx, layout as SankeyChartLayout, { linkOpacity }), [layout, linkOpacity] ) + // 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 => { + if (cached?.index !== index) { + const untyped = layout as SankeyChartLayout + cached = { index, flow: sankeyActiveFlow(untyped, hoverIndexToHit(untyped, index)) } + } + return cached.flow + } + }, [layout]) const drawHover = useCallback( ({ ctx: drawCtx, hoverIndex: index, hoverProgress, theme: drawTheme }: ChartDrawArgs): boolean => - drawSankeyHover(drawCtx, layout as SankeyChartLayout, hoverIndexToHit(layout, index), { + drawSankeyHover(drawCtx, layout as SankeyChartLayout, activeFlowFor(index), { linkOpacity, backgroundColor: drawTheme.backgroundColor, progress: hoverProgress, }), - [layout, linkOpacity] + [layout, linkOpacity, activeFlowFor] ) useChartDraw({ @@ -161,7 +177,7 @@ function SankeyChartInner({ () => tooltip ?? ((tooltipContext: SankeyTooltipContext): React.ReactNode => ( - + )), [tooltip, valueFormatter] ) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx deleted file mode 100644 index 2116137d9cfd..000000000000 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyTooltip.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react' - -import { TooltipSurface, TooltipSwatch } from '../../overlays/TooltipSurface' -import { defaultValueFormatter } from './sankey-data' -import type { SankeyTooltipContext } from './types' - -export interface SankeyTooltipProps { - ctx: SankeyTooltipContext - valueFormatter?: (value: number) => React.ReactNode -} - -function formatShare(fraction: number): string { - return `${Math.round(fraction * 1000) / 10}%` -} - -/** Default tooltip: the node label, or `source → target` for a ribbon, then the value and its - * share of the total inflow. */ -export function SankeyTooltip({ - ctx, - valueFormatter = defaultValueFormatter, -}: SankeyTooltipProps): React.ReactElement { - const { hit, total } = ctx - const { title, value, color } = - hit.kind === 'node' - ? { title: hit.node.label, value: hit.node.value, color: hit.node.color } - : { - title: `${hit.link.source.label} → ${hit.link.target.label}`, - value: hit.link.value, - color: hit.link.color, - } - const share = total > 0 ? value / total : 0 - - return ( - -
- - {title} -
-
- {valueFormatter(value)} - {share > 0 ? ({formatShare(share)}) : null} -
-
- ) -} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index 60aed6a4ac07..d8f560afec0a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -48,38 +48,52 @@ export function drawSankey(ctx: CanvasRenderingContext2D, layout: SankeyChartLay } } -/** Hover layer: dims the graph toward the background, then repaints the hovered node or ribbon - * and everything connected to it at full strength. Returns false when nothing is hovered. */ -export function drawSankeyHover( - ctx: CanvasRenderingContext2D, - layout: SankeyChartLayout, - hit: SankeyHit | null, - options: DrawSankeyOptions & { backgroundColor?: string; progress: number } -): boolean { +export interface SankeyActiveFlow { + hit: SankeyHit + links: Set + nodes: Set +} + +/** The hovered node or ribbon plus everything connected to it, or `null` when nothing is hovered. */ +export function sankeyActiveFlow(layout: SankeyChartLayout, hit: SankeyHit | null): SankeyActiveFlow | null { if (!hit) { - return false + return null } - const dimTarget = options.backgroundColor || HOVER_DIM_TARGET_FALLBACK - const dim = options.progress * HOVER_DIM_AMOUNT - - const activeLinks = new Set() - const activeNodes = new Set() + const links = new Set() + const nodes = new Set() if (hit.kind === 'node') { const node = layout.nodes[hit.index] - activeNodes.add(node) + nodes.add(node) for (const link of layout.links) { if (link.source === node || link.target === node) { - activeLinks.add(link) - activeNodes.add(link.source) - activeNodes.add(link.target) + links.add(link) + nodes.add(link.source) + nodes.add(link.target) } } } else { const link = layout.links[hit.index] - activeLinks.add(link) - activeNodes.add(link.source) - activeNodes.add(link.target) + links.add(link) + nodes.add(link.source) + nodes.add(link.target) } + return { hit, links, nodes } +} + +/** Hover layer: dims the graph toward the background, then repaints the active flow at full + * strength. Returns false when nothing is hovered. */ +export function drawSankeyHover( + ctx: CanvasRenderingContext2D, + layout: SankeyChartLayout, + flow: SankeyActiveFlow | null, + options: DrawSankeyOptions & { backgroundColor?: string; progress: number } +): boolean { + if (!flow) { + return false + } + const { hit, links: activeLinks, nodes: activeNodes } = flow + const dimTarget = options.backgroundColor || HOVER_DIM_TARGET_FALLBACK + const dim = options.progress * HOVER_DIM_AMOUNT // The dim fill must be opaque: a translucent repaint composites over the full-color static // layer and does not dim at all. diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index d7cfea1c0ac2..a4bbd6117573 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -59,10 +59,11 @@ function buildTooltipCtx( // One synthetic series row keeps the shared tooltip plumbing (equivalence checks, the // default renderer fallback) working on a chart that has no series. const series: Series = { key: `${hit.kind}:${hit.index}`, label, data: [value], color, meta } + const fraction = layout.total > 0 ? value / layout.total : 0 return { dataIndex: hitToHoverIndex(layout, hit), label, - seriesData: [{ series, value, color }], + seriesData: [{ series, value, color, fraction }], position: anchor, hoverPosition: cursor, canvasBounds, @@ -108,15 +109,24 @@ export function useSankeyInteraction({ } }, [layout, isPinned, clearTooltip]) + // Hidden content must not survive to reappear when tooltips are switched back on. + useEffect(() => { + if (!showTooltip) { + setTooltipCtx(null) + } + }, [showTooltip, setTooltipCtx]) + const hoverIndexRef = useLatest(hoverIndex) const showHit = useCallback( (hit: SankeyHit, cursor: { x: number; y: number }) => { setHover(hitToHoverIndex(layoutRef.current, hit), cursor) - if (showTooltip) { - const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() - setTooltipCtx(buildTooltipCtx(layoutRef.current, hit, cursor, canvasBounds)) + if (!showTooltip) { + setTooltipCtx(null) + return } + const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() + setTooltipCtx(buildTooltipCtx(layoutRef.current, hit, cursor, canvasBounds)) }, [layoutRef, showTooltip, setHover, setTooltipCtx, canvasRef] ) diff --git a/packages/quill/packages/charts/src/core/types.ts b/packages/quill/packages/charts/src/core/types.ts index cbbbfbdf5302..5afdfe1166f5 100644 --- a/packages/quill/packages/charts/src/core/types.ts +++ b/packages/quill/packages/charts/src/core/types.ts @@ -48,7 +48,7 @@ export interface Series { data: number[] /** CSS color string (hex, rgb) for the line and associated fill/points. Line, area, and bar * series hand it to the canvas unresolved, so resolve `var(--…)` in the host first; only - * `Heatmap` and `ScatterChart` resolve it themselves. When omitted (or empty), the chart + * `Heatmap`, `ScatterChart`, and `SankeyChart` resolve it themselves. When omitted (or empty), the chart * picks a color from `theme.colors` by series index. */ color?: string /** Bar charts only: per-bar overrides of the series-level `color`/`label`/`meta`, indexed by diff --git a/packages/quill/packages/charts/src/index.ts b/packages/quill/packages/charts/src/index.ts index 64565f813971..03025f645b54 100644 --- a/packages/quill/packages/charts/src/index.ts +++ b/packages/quill/packages/charts/src/index.ts @@ -129,8 +129,6 @@ export type { SankeyNodeDatum, SankeyNodeInput, } from './charts/SankeyChart/sankey-data' -export { SankeyTooltip } from './charts/SankeyChart/SankeyTooltip' -export type { SankeyTooltipProps } from './charts/SankeyChart/SankeyTooltip' export { useSankeyLayout } from './charts/SankeyChart/sankey-context' export type { SankeyLayoutContextValue } from './charts/SankeyChart/sankey-context' // The layout engine on its own, for hosts that draw their own SVG (user paths). diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index b77161652e7a..5c26dc5eed56 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -81,7 +81,7 @@ export function ClusterJourneySankey({ journey }: Props): JSX.Element | null { const tooltip = useMemo(() => makeJourneyTooltip(journey?.total_sessions ?? 0), [journey?.total_sessions]) - if (!journey || !graph || graph.links.length === 0) { + if (!journey || !graph || !graph.links.some((link) => link.value > 0)) { return (
Not enough session data yet to plot a journey. Recompute after more sessions are summarised. From 0def4f7953cc2987126b93a8262c0913c2ff360b Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:43:47 +0100 Subject: [PATCH 21/80] fix(quill-charts): address second sankey review round - describe a leak path with one Ended for its padding, keeping a tool named Ended - stop the engine's default link type from typing laid-out endpoints as strings - note that sankeyLayout does not validate its input Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/sankey-layout.ts | 4 +++- packages/quill/packages/charts/src/docs/chart-types.md | 1 + .../frontend/clustering/clusterJourneyGraph.test.ts | 7 ++++++- .../frontend/clustering/clusterJourneyGraph.ts | 8 ++++++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index 016ed227cab4..a1ab2335eee0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -192,7 +192,9 @@ function computeLinkBreadths(): SankeyLayout { let x0 = 0, y0 = 0, diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 948ec967813d..7b92a94bbfb5 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -96,6 +96,7 @@ There is no `series` or `labels`. On touch, the first tap on a node or ribbon shows its tooltip and a second tap on the same one fires the click handler. - Custom overlays read `useSankeyLayout()` for the positioned `nodes`, `links`, `columnX`, and `total`. - The layout engine ships on its own as `sankeyLayout` (plus the `sankeyLeft` / `sankeyJustify` alignments and `sankeyLinkHorizontal`) for hosts that draw their own SVG. + It does not validate its input: the checks above run in `computeSankeyLayout`, so a host that calls the engine directly validates its own graph. ## Sparkline diff --git a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts index 1d1091f8dd6d..cc6ab670954a 100644 --- a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts +++ b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.test.ts @@ -1,5 +1,5 @@ import type { MCPIntentClusterJourneyPathApi } from '../generated/api.schemas' -import { buildJourneyGraph } from './clusterJourneyGraph' +import { buildJourneyGraph, describeJourneyPath } from './clusterJourneyGraph' const PATHS: MCPIntentClusterJourneyPathApi[] = [ { steps: ['schema', 'sql', null], outcome: 'completed', count: 20 }, @@ -44,4 +44,9 @@ describe('buildJourneyGraph', () => { expect(graph.links.filter((link) => link.target === '1::tool::Ended').map((link) => link.value)).toEqual([2]) expect(graph.links.filter((link) => link.target === '1::ended::Ended').map((link) => link.value)).toEqual([3]) }) + + it('describes a leak path with one Ended for its padding and keeps a tool named Ended', () => { + expect(describeJourneyPath({ steps: ['sql', null, null], outcome: 'error', count: 1 })).toBe('sql → Ended') + expect(describeJourneyPath({ steps: ['Ended', null, null], outcome: 'error', count: 1 })).toBe('Ended → Ended') + }) }) diff --git a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts index fb6ec5fb93de..23ca53f50034 100644 --- a/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts +++ b/products/mcp_analytics/frontend/clustering/clusterJourneyGraph.ts @@ -67,7 +67,11 @@ export function buildJourneyGraph(paths: readonly MCPIntentClusterJourneyPathApi return { nodes: Array.from(nodes.values()), links: Array.from(links.values()) } } +/** The tools a path ran, then one `Ended` for the missing steps that pad it. A tool that happens + * to be named `Ended` still shows. */ export function describeJourneyPath(path: MCPIntentClusterJourneyPathApi): string { - const labels = path.steps.map((step) => step ?? ENDED_LABEL) - return labels.filter((label, idx) => label !== ENDED_LABEL || idx === labels.indexOf(ENDED_LABEL)).join(' → ') + const firstMissing = path.steps.findIndex((step) => step == null) + return path.steps + .flatMap((step, idx) => (step != null ? [step] : idx === firstMissing ? [ENDED_LABEL] : [])) + .join(' → ') } From ac2221fa09a763cc72108b7bffc8fd28d080b3dd Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 16:26:16 +0100 Subject: [PATCH 22/80] fix(quill-charts): address sankey triage round Dim inactive ribbons only toward a background the canvas can parse, keep nodes above ribbons on hover, validate node sizing, clamp link opacity, serialize the reset key unambiguously, gate touch taps on the visible tooltip, and skip explicitly colored nodes when assigning palette slots. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/SankeyChart.tsx | 16 +++- .../charts/SankeyChart/draw-sankey.test.ts | 76 +++++++++++++++++++ .../src/charts/SankeyChart/draw-sankey.ts | 47 +++++++++--- .../charts/SankeyChart/sankey-data.test.ts | 8 ++ .../src/charts/SankeyChart/sankey-data.ts | 8 +- .../SankeyChart/useSankeyInteraction.ts | 10 ++- .../clustering/ClusterJourneySankey.tsx | 4 +- 7 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 95b72515b034..68216bea45ee 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -36,9 +36,11 @@ const NO_SCALES: ChartScales = { x: () => undefined, y: () => 0, yTicks: () => [ /** Changes whenever the layout could validate differently, so a corrected graph clears the error. */ function graphKey(nodes: SankeyNodeInput[], links: SankeyLinkInput[]): string { - const nodeIds = nodes.map((node) => node.id).join(',') - const linkKeys = links.map((link) => `${link.source}>${link.target}=${link.value}`).join(',') - return `${nodeIds}|${linkKeys}` + // Structured serialization: ids are free-form strings, so a delimiter inside one must not collide. + return JSON.stringify([ + nodes.map((node) => node.id), + links.map(({ source, target, value }) => [source, target, value]), + ]) } export function SankeyChart({ @@ -72,11 +74,14 @@ function SankeyChartInner({ columnLabels, showNodeLabels = true, showNodeValues = false, - linkOpacity = DEFAULT_LINK_OPACITY, + linkOpacity: configuredLinkOpacity = DEFAULT_LINK_OPACITY, valueFormatter = defaultValueFormatter, tooltip: tooltipConfig, margins: marginsOverride, } = config ?? {} + const linkOpacity = Number.isFinite(configuredLinkOpacity) + ? Math.min(1, Math.max(0, configuredLinkOpacity)) + : DEFAULT_LINK_OPACITY const showTooltip = tooltipConfig?.enabled !== false const hasColumnLabels = !!columnLabels && columnLabels.length > 0 @@ -93,6 +98,9 @@ function SankeyChartInner({ const colorForLabel = useMemo(() => { const slots = new Map() for (const node of nodes) { + if (node.color) { + continue + } const label = node.label ?? node.id if (!slots.has(label)) { slots.set(label, theme.colors[slots.size % theme.colors.length]) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts new file mode 100644 index 000000000000..2e01c7c0a5a5 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -0,0 +1,76 @@ +import { drawSankeyHover, sankeyActiveFlow } from './draw-sankey' +import { computeSankeyLayout } from './sankey-data' + +const BACKGROUND = 'hsl(235deg 8% 15%)' + +function layoutOf(): ReturnType { + return computeSankeyLayout({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + links: [ + { source: 'a', target: 'b', value: 10 }, + { source: 'a', target: 'c', value: 10 }, + ], + plot: { plotLeft: 0, plotTop: 0, plotWidth: 600, plotHeight: 300 }, + nodeWidth: 10, + nodePadding: 8, + nodeAlign: 'justify', + preserveNodeOrder: false, + colorForLabel: () => '#ff0000', + resolveColor: (c) => c, + }) +} + +/** Records stroke colors. Like a browser canvas it only accepts colors it can parse. */ +function recordingCtx(serialize: Record): { ctx: CanvasRenderingContext2D; strokes: string[] } { + const strokes: string[] = [] + let fill = '#000000' + let stroke = '#000000' + const ctx = { + globalAlpha: 1, + lineWidth: 1, + get fillStyle(): string { + return fill + }, + set fillStyle(value: string) { + fill = serialize[value] ?? (value.startsWith('#') ? value : fill) + }, + get strokeStyle(): string { + return stroke + }, + set strokeStyle(value: string) { + stroke = value + }, + beginPath: () => {}, + moveTo: () => {}, + bezierCurveTo: () => {}, + save: () => {}, + restore: () => {}, + fillRect: () => {}, + stroke: () => strokes.push(stroke), + } as unknown as CanvasRenderingContext2D + return { ctx, strokes } +} + +describe('drawSankeyHover', () => { + it.each([ + ['serialized by the canvas', { [BACKGROUND]: '#202023' }, 2], + ['rejected by the canvas', {}, 1], + ])( + 'never paints inactive ribbons in a background d3-color cannot parse: %s', + (_name, serialize, expectedStrokes) => { + const layout = layoutOf() + const { ctx, strokes } = recordingCtx(serialize) + drawSankeyHover(ctx, layout, sankeyActiveFlow(layout, { kind: 'link', index: 0 }), { + linkOpacity: 0.4, + backgroundColor: BACKGROUND, + progress: 1, + }) + + // A usable background dims the inactive ribbon to an opaque mix; otherwise only the active + // ribbon is repainted and the static layer keeps the inactive one as it was. + expect(strokes).toHaveLength(expectedStrokes) + expect(strokes).not.toContain(BACKGROUND) + expect(strokes).not.toContain('#202023') + } + ) +}) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index d8f560afec0a..e5e7dcf5ae35 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -1,3 +1,5 @@ +import { color as d3Color } from 'd3-color' + import { mixColors } from '../../core/color-utils' import type { SankeyChartLayout, SankeyHit, SankeyLinkDatum, SankeyNodeDatum } from './sankey-data' @@ -80,6 +82,26 @@ export function sankeyActiveFlow(layout: SankeyChartLayout, hit: SankeyHit | nul return { hit, links, nodes } } +/** A color `mixColors` can interpolate, or `null`. d3-color cannot parse modern CSS syntax such as + * `hsl(235deg 8% 15%)` or `oklch(...)`, so those go through the canvas, which serializes any color + * it accepts to hex or rgba. An invalid color leaves `fillStyle` unchanged, so two different + * sentinels tell it apart from a valid one. */ +function parseableColor(ctx: CanvasRenderingContext2D, color: string): string | null { + if (d3Color(color)) { + return color + } + const previous = ctx.fillStyle + const read = (sentinel: string): string => { + ctx.fillStyle = sentinel + ctx.fillStyle = color + return String(ctx.fillStyle) + } + const first = read('#000000') + const second = read('#ffffff') + ctx.fillStyle = previous + return first === second && d3Color(first) ? first : null +} + /** Hover layer: dims the graph toward the background, then repaints the active flow at full * strength. Returns false when nothing is hovered. */ export function drawSankeyHover( @@ -92,26 +114,33 @@ export function drawSankeyHover( return false } const { hit, links: activeLinks, nodes: activeNodes } = flow - const dimTarget = options.backgroundColor || HOVER_DIM_TARGET_FALLBACK + const dimTarget = parseableColor(ctx, options.backgroundColor || HOVER_DIM_TARGET_FALLBACK) const dim = options.progress * HOVER_DIM_AMOUNT // The dim fill must be opaque: a translucent repaint composites over the full-color static - // layer and does not dim at all. + // layer and does not dim at all. A ribbon with nothing parseable to mix is left to the static + // layer: any repaint over it would either darken it or paint it in the background color. for (const link of layout.links) { - if (!activeLinks.has(link)) { - const resting = mixColors(dimTarget, link.color, options.linkOpacity) - strokeLink(ctx, link, mixColors(resting, dimTarget, dim), 1) + if (activeLinks.has(link) || !dimTarget) { + continue } - } - for (const node of layout.nodes) { - if (!activeNodes.has(node)) { - fillNode(ctx, node, mixColors(node.color, dimTarget, dim)) + const linkColor = parseableColor(ctx, link.color) + if (linkColor) { + const resting = mixColors(dimTarget, linkColor, options.linkOpacity) + // Fading the opaque dim in with progress keeps the first frame identical to the static layer. + strokeLink(ctx, link, mixColors(resting, dimTarget, dim), options.progress) } } for (const link of activeLinks) { const opacity = options.linkOpacity + (HOVER_LINK_OPACITY - options.linkOpacity) * options.progress strokeLink(ctx, link, link.color, opacity) } + // Nodes go last so a ribbon that skips a column never paints over the node it passes behind. + for (const node of layout.nodes) { + if (!activeNodes.has(node) && dimTarget) { + fillNode(ctx, node, mixColors(node.color, dimTarget, dim)) + } + } for (const node of activeNodes) { const highlight = hit.kind === 'node' && node === layout.nodes[hit.index] const color = highlight diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index b028d834f3c1..a9299a2e4722 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -79,6 +79,14 @@ describe('computeSankeyLayout', () => { ) }) + it.each([ + ['a zero nodeWidth', { nodeWidth: 0 }, 'nodeWidth'], + ['a NaN nodeWidth', { nodeWidth: NaN }, 'nodeWidth'], + ['a negative nodePadding', { nodePadding: -1 }, 'nodePadding'], + ])('throws on %s so the error boundary reports it', (_name, overrides, message) => { + expect(() => layoutOf(overrides)).toThrow(message) + }) + it('returns an empty layout when all link values are zero', () => { const layout = layoutOf({ links: [{ source: 'start', target: 'a', value: 0 }] }) expect(layout.nodes).toHaveLength(0) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 4fa57ec690fb..965fd62271a5 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -165,6 +165,12 @@ export function computeSankeyLayout({ throw new Error(`Sankey link refers to a missing node: ${link.source} -> ${link.target}`) } } + if (!Number.isFinite(nodeWidth) || nodeWidth <= 0) { + throw new Error(`Sankey nodeWidth must be a finite number above 0: ${nodeWidth}`) + } + if (!Number.isFinite(nodePadding) || nodePadding < 0) { + throw new Error(`Sankey nodePadding must be a finite number of 0 or more: ${nodePadding}`) + } const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, nodes.length - 1)) // The engine mutates its inputs, so hand it fresh objects. @@ -224,7 +230,7 @@ export function computeSankeyLayout({ } }) - const columnCount = Math.max(0, ...graph.nodes.map((node) => node.layer + 1)) + const columnCount = graph.nodes.reduce((max, node) => Math.max(max, node.layer + 1), 0) // Mirrors the engine's column spacing, so a column with no node still gets a position. const columnStep = columnCount <= 1 ? 0 : (plot.plotWidth - effectiveNodeWidth) / (columnCount - 1) const columnX = Array.from({ length: columnCount }, (_, column) => plot.plotLeft + column * columnStep) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index a4bbd6117573..9b9046f90780 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -117,6 +117,7 @@ export function useSankeyInteraction({ }, [showTooltip, setTooltipCtx]) const hoverIndexRef = useLatest(hoverIndex) + const tooltipCtxRef = useLatest(tooltipCtx) const showHit = useCallback( (hit: SankeyHit, cursor: { x: number; y: number }) => { @@ -158,14 +159,15 @@ export function useSankeyInteraction({ // only a tap on the element already showing one fires the click handler. Both refs are read // at pointerdown because a tap's compatibility mouse events arrive after pointerup. const lastPointerTypeRef = useRef('mouse') - const tapDownHoverIndexRef = useRef(-1) + const tapDownTooltipIndexRef = useRef(-1) const onPointerDown = useCallback( (e: React.PointerEvent) => { lastPointerTypeRef.current = e.pointerType - tapDownHoverIndexRef.current = hoverIndexRef.current + // The visible tooltip, not the hover: hover can outlive a tooltip that was just hidden. + tapDownTooltipIndexRef.current = tooltipCtxRef.current?.dataIndex ?? -1 }, - [hoverIndexRef] + [tooltipCtxRef] ) const onClick = useCallback( @@ -180,7 +182,7 @@ export function useSankeyInteraction({ clearTooltip() return } - if (hitToHoverIndex(current, hit) !== tapDownHoverIndexRef.current) { + if (hitToHoverIndex(current, hit) !== tapDownTooltipIndexRef.current) { showHit(hit, cursor) if (showTooltip) { return diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index 5c26dc5eed56..d1468a47a0dd 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -48,7 +48,9 @@ function nodeFill(kind: JourneyNodeKind, theme: ChartTheme): string { } // Shares divide by every session in the cluster, not just the displayed top paths, so a subset never reads as 100%. -function makeJourneyTooltip(totalSessions: number) { +function makeJourneyTooltip( + totalSessions: number +): (ctx: SankeyTooltipContext) => JSX.Element { return function JourneyTooltipAdapter({ hit, }: SankeyTooltipContext): JSX.Element { From f2f95a2f9603f912579637d06e882b9277581f58 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 16:28:49 +0100 Subject: [PATCH 23/80] refactor(quill-charts): apply simplify pass Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx index c7ab395f3081..c6a59502d4ba 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -35,7 +35,7 @@ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNo {layout.nodes.map((node) => { const text = showValues ? `${node.label} ${valueFormatter(node.value)}` : node.label const last = node.column === layout.columnCount - 1 && layout.columnCount > 1 - const shown = truncateToWidth(text, isFinite(maxWidth) ? maxWidth : Infinity, LABEL_FONT) + const shown = truncateToWidth(text, maxWidth, LABEL_FONT) const style: React.CSSProperties = { ...LABEL_STYLE_BASE, color, From ef9fa52158a14dd068ad18cd699c4a968b9d189b Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 17:10:06 +0100 Subject: [PATCH 24/80] fix(quill-charts): cap sankey node width by column count, not node count A dense graph with many nodes in few columns no longer squeezes its bars. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/sankey-data.test.ts | 10 +++++ .../src/charts/SankeyChart/sankey-data.ts | 38 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index a9299a2e4722..3cdd50bad20c 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -72,6 +72,16 @@ describe('computeSankeyLayout', () => { expect(aToDone?.color).toBe('#aaaaaa') }) + it('caps node width by the column count, not the node count', () => { + const targets = Array.from({ length: 200 }, (_, i): SankeyNodeInput => ({ id: `t${i}` })) + const dense = layoutOf({ + nodes: [{ id: 'start' }, ...targets], + links: targets.map((target) => ({ source: 'start', target: target.id, value: 1 })), + }) + expect(dense.columnCount).toBe(2) + expect(dense.nodeWidth).toBe(10) + }) + it('returns an empty layout without links and throws on a link to a missing node', () => { expect(layoutOf({ links: [] }).nodes).toHaveLength(0) expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow( diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 965fd62271a5..304204e440b4 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -131,6 +131,42 @@ export const EMPTY_SANKEY_LAYOUT: SankeyChartLayout = { nodeWidth: 0, } +/** Nodes on the longest path through the graph, which is the column count the layout engine + * gives it. A cycle stops the count early; the engine reports the cycle itself. */ +function longestPathLength( + nodes: readonly SankeyNodeInput[], + links: readonly SankeyLinkInput[] +): number { + const incoming = new Map(nodes.map((node) => [node.id, 0])) + const outgoing = new Map() + for (const link of links) { + incoming.set(link.target, (incoming.get(link.target) ?? 0) + 1) + const targets = outgoing.get(link.source) + if (targets) { + targets.push(link.target) + } else { + outgoing.set(link.source, [link.target]) + } + } + const depth = new Map() + const ready = nodes.filter((node) => incoming.get(node.id) === 0).map((node) => node.id) + let longest = 1 + while (ready.length > 0) { + const id = ready.pop()! + const next = (depth.get(id) ?? 1) + 1 + for (const target of outgoing.get(id) ?? []) { + depth.set(target, Math.max(depth.get(target) ?? 1, next)) + longest = Math.max(longest, next) + const remaining = incoming.get(target)! - 1 + incoming.set(target, remaining) + if (remaining === 0) { + ready.push(target) + } + } + } + return longest +} + /** Lays the graph out inside `plot`. Pure: safe to call from a memo or a test. Throws when a link * names a missing node or the graph has a cycle, so a consumer bug surfaces through the chart's * error boundary instead of drawing nothing. Returns `EMPTY_SANKEY_LAYOUT` when the graph has no @@ -171,7 +207,7 @@ export function computeSankeyLayout({ if (!Number.isFinite(nodePadding) || nodePadding < 0) { throw new Error(`Sankey nodePadding must be a finite number of 0 or more: ${nodePadding}`) } - const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, nodes.length - 1)) + const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, longestPathLength(nodes, links) - 1)) // The engine mutates its inputs, so hand it fresh objects. const engineNodes: LayoutNodeProps[] = nodes.map((node) => { From 8aee48a99e882750b05cf7d0a8b7ea029cf9fb89 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 23:58:27 +0100 Subject: [PATCH 25/80] fix(quill-charts): address sankey review round Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/SankeyChart.tsx | 13 +++++++++---- .../charts/SankeyChart/draw-sankey.test.ts | 13 +++++++++++++ .../src/charts/SankeyChart/draw-sankey.ts | 19 ++++++++++++++----- .../src/charts/SankeyChart/sankey-data.ts | 5 ++--- .../SankeyChart/useSankeyInteraction.ts | 3 ++- .../src/core/hooks/useChartInteraction.ts | 2 +- 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 68216bea45ee..08797c1a5d91 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -35,11 +35,16 @@ const DEFAULT_LABEL_COLOR = 'rgba(0, 0, 0, 0.7)' const NO_SCALES: ChartScales = { x: () => undefined, y: () => 0, yTicks: () => [] } /** Changes whenever the layout could validate differently, so a corrected graph clears the error. */ -function graphKey(nodes: SankeyNodeInput[], links: SankeyLinkInput[]): string { +function graphKey( + nodes: SankeyNodeInput[], + links: SankeyLinkInput[], + config: SankeyChartProps['config'] +): string { // Structured serialization: ids are free-form strings, so a delimiter inside one must not collide. return JSON.stringify([ nodes.map((node) => node.id), links.map(({ source, target, value }) => [source, target, value]), + [config?.nodeWidth, config?.nodePadding, config?.nodeAlign, config?.preserveNodeOrder], ]) } @@ -48,7 +53,7 @@ export function SankeyChart({ ...rest }: SankeyChartProps): React.ReactElement { return ( - + ) @@ -127,8 +132,8 @@ function SankeyChartInner({ // The shared draw loop keys repaints on `scales` identity, so wrap the layout in one. const scales = useMemo( - () => (dimensions ? { ...NO_SCALES, _private: { __sankey: layout } } : null), - [dimensions, layout] + () => (dimensions ? { ...NO_SCALES, _private: { __sankey: layout, linkOpacity } } : null), + [dimensions, layout, linkOpacity] ) const { hoverIndex, hoverPosition, tooltipCtx, handlers } = useSankeyInteraction({ diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index 2e01c7c0a5a5..4cf11434ba99 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -73,4 +73,17 @@ describe('drawSankeyHover', () => { expect(strokes).not.toContain('#202023') } ) + + it('leaves a transparent ribbon to the static layer instead of mixing NaN channels', () => { + const layout = layoutOf() + layout.links[1].color = 'transparent' + const { ctx, strokes } = recordingCtx({ [BACKGROUND]: '#202023' }) + drawSankeyHover(ctx, layout, sankeyActiveFlow(layout, { kind: 'link', index: 0 }), { + linkOpacity: 0.4, + backgroundColor: BACKGROUND, + progress: 1, + }) + + expect(strokes).toHaveLength(1) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index e5e7dcf5ae35..a1a33c551b5b 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -82,12 +82,17 @@ export function sankeyActiveFlow(layout: SankeyChartLayout, hit: SankeyHit | nul return { hit, links, nodes } } +function isMixable(parsed: ReturnType | null): boolean { + const rgb = parsed?.rgb() + return !!rgb && Number.isFinite(rgb.r) && Number.isFinite(rgb.g) && Number.isFinite(rgb.b) +} + /** A color `mixColors` can interpolate, or `null`. d3-color cannot parse modern CSS syntax such as * `hsl(235deg 8% 15%)` or `oklch(...)`, so those go through the canvas, which serializes any color * it accepts to hex or rgba. An invalid color leaves `fillStyle` unchanged, so two different * sentinels tell it apart from a valid one. */ function parseableColor(ctx: CanvasRenderingContext2D, color: string): string | null { - if (d3Color(color)) { + if (isMixable(d3Color(color))) { return color } const previous = ctx.fillStyle @@ -99,7 +104,7 @@ function parseableColor(ctx: CanvasRenderingContext2D, color: string): string | const first = read('#000000') const second = read('#ffffff') ctx.fillStyle = previous - return first === second && d3Color(first) ? first : null + return first === second && isMixable(d3Color(first)) ? first : null } /** Hover layer: dims the graph toward the background, then repaints the active flow at full @@ -138,13 +143,17 @@ export function drawSankeyHover( // Nodes go last so a ribbon that skips a column never paints over the node it passes behind. for (const node of layout.nodes) { if (!activeNodes.has(node) && dimTarget) { - fillNode(ctx, node, mixColors(node.color, dimTarget, dim)) + const nodeColor = parseableColor(ctx, node.color) + if (nodeColor) { + fillNode(ctx, node, mixColors(nodeColor, dimTarget, dim)) + } } } for (const node of activeNodes) { const highlight = hit.kind === 'node' && node === layout.nodes[hit.index] - const color = highlight - ? mixColors(node.color, HOVER_HIGHLIGHT_TARGET, HOVER_HIGHLIGHT_AMOUNT * options.progress) + const highlightBase = highlight ? parseableColor(ctx, node.color) : null + const color = highlightBase + ? mixColors(highlightBase, HOVER_HIGHLIGHT_TARGET, HOVER_HIGHLIGHT_AMOUNT * options.progress) : node.color fillNode(ctx, node, color) } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 304204e440b4..e5a68f18bfef 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -151,13 +151,12 @@ function longestPathLength( const depth = new Map() const ready = nodes.filter((node) => incoming.get(node.id) === 0).map((node) => node.id) let longest = 1 - while (ready.length > 0) { - const id = ready.pop()! + for (let id = ready.pop(); id !== undefined; id = ready.pop()) { const next = (depth.get(id) ?? 1) + 1 for (const target of outgoing.get(id) ?? []) { depth.set(target, Math.max(depth.get(target) ?? 1, next)) longest = Math.max(longest, next) - const remaining = incoming.get(target)! - 1 + const remaining = (incoming.get(target) ?? 0) - 1 incoming.set(target, remaining) if (remaining === 0) { ready.push(target) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 9b9046f90780..b14065bb16e4 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react' +import { originatesInInteractiveOverlay } from '../../core/hooks/useChartInteraction' import { useLatest } from '../../core/hooks/useLatest' import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' import type { Series, TooltipContext } from '../../core/types' @@ -135,7 +136,7 @@ export function useSankeyInteraction({ const onMouseMove = useCallback( (e: React.MouseEvent) => { const current = layoutRef.current - if (current.nodes.length === 0) { + if (current.nodes.length === 0 || originatesInInteractiveOverlay(e)) { return } const rect = e.currentTarget.getBoundingClientRect() diff --git a/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts b/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts index f9127205e239..81c2a3dec981 100644 --- a/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts +++ b/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts @@ -40,7 +40,7 @@ function originatesInTooltip(e: React.SyntheticEvent): boolean { * Without this guard the chart's own nearest-point tooltip fights the overlay child's tooltip * for the cursor. An overlay opts out of chart hover tracking by marking its interactive root * with this attribute. */ -function originatesInInteractiveOverlay(e: React.SyntheticEvent): boolean { +export function originatesInInteractiveOverlay(e: React.SyntheticEvent): boolean { return originatesInElement(e, '[data-hog-charts-interactive-overlay]') } From 641542324f9bc71728b44c75df9827610f114dcd Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 00:00:42 +0100 Subject: [PATCH 26/80] fix(quill-charts): clear sankey hover on interactive overlays Moving onto an interactive overlay clears the tooltip, and a click there no longer fires the node or link click handler. Tests cover the overlay guard and recovery after a corrected layout config. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 30 +++++++++++++++++-- .../SankeyChart/useSankeyInteraction.ts | 7 +++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index b4933a36972e..3a4207faa6fc 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -118,12 +118,36 @@ describe('SankeyChart', () => { expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' })) }) - it('recovers from an invalid graph when only a link endpoint is corrected', () => { + it('ignores hover and clicks from an interactive overlay child', async () => { + const onNodeClick = jest.fn() + const { chart } = renderHogChart( + + + , + { nativeTooltip: true } + ) + await waitFor(() => { + fireEvent.mouseMove(chart.element, nodeCenter('a')) + expect(getHogChartTooltip()?.textContent).toContain('Tool A') + }) + const overlay = chart.element.querySelector('[data-hog-charts-interactive-overlay]')! + fireEvent.mouseMove(overlay, nodeCenter('a')) + await waitFor(() => expect(getHogChartTooltip()).toBeNull()) + fireEvent.click(overlay, nodeCenter('a')) + expect(onNodeClick).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'a link endpoint', + 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 }) => { jest.spyOn(console, 'error').mockImplementation(() => {}) const onError = jest.fn() - const broken: SankeyLinkInput[] = [LINKS[0], { source: 'a', target: 'missing', value: 12 }] const { container, rerender } = render( - + ) expect(onError).toHaveBeenCalled() expect(container.textContent).toContain('Something went wrong') diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index b14065bb16e4..65a2583c9ffc 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -136,12 +136,12 @@ export function useSankeyInteraction({ const onMouseMove = useCallback( (e: React.MouseEvent) => { const current = layoutRef.current - if (current.nodes.length === 0 || originatesInInteractiveOverlay(e)) { + if (current.nodes.length === 0) { return } const rect = e.currentTarget.getBoundingClientRect() const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } - const hit = sankeyHitAt(current, cursor) + const hit = originatesInInteractiveOverlay(e) ? null : sankeyHitAt(current, cursor) if (!hit) { clearTooltip() return @@ -173,6 +173,9 @@ export function useSankeyInteraction({ const onClick = useCallback( (e: React.MouseEvent) => { + if (originatesInInteractiveOverlay(e)) { + return + } const current = layoutRef.current const rect = e.currentTarget.getBoundingClientRect() const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } From 5e5acb36086fa2c82ddf7e530b77e542963dd089 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 00:01:46 +0100 Subject: [PATCH 27/80] refactor: apply simplify pass Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/charts/src/charts/SankeyChart/sankey-data.ts | 6 ------ .../quill/packages/charts/src/charts/SankeyChart/types.ts | 5 +---- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index e5a68f18bfef..d5f9d08c1e46 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -19,7 +19,6 @@ export interface SankeyNodeInput { export interface SankeyLinkInput { source: string target: string - /** Flow magnitude. Drives the ribbon width and the node heights. */ value: number /** CSS color for the ribbon. Defaults to the source node's color. */ color?: string @@ -30,13 +29,11 @@ export interface SankeyLinkInput { /** How nodes spread across columns when a flow ends early. Mirrors d3-sankey's alignments. */ export type SankeyNodeAlign = 'left' | 'right' | 'center' | 'justify' -/** A laid-out node: the input plus its pixel box, column, and total flow. */ export interface SankeyNodeDatum { id: string label: string color: string meta?: Meta - /** Zero-based column, left to right. */ column: number /** Sum of the larger side's link values (in or out). */ value: number @@ -62,7 +59,6 @@ export interface SankeyLinkDatum { export interface SankeyChartLayout { nodes: SankeyNodeDatum[] links: SankeyLinkDatum[] - /** Number of columns the nodes occupy. */ columnCount: number /** Pixel x of each column's left edge, so overlays can place column headers. */ columnX: number[] @@ -86,9 +82,7 @@ export interface ComputeSankeyLayoutOptions { nodeWidth: number nodePadding: number nodeAlign: SankeyNodeAlign - /** Keep the input order of nodes within a column instead of sorting by flow position. */ preserveNodeOrder: boolean - /** Resolves a node's fill when it sets no `color`. Receives the node's label. */ colorForLabel: (label: string) => string /** Applied to every color before it reaches the canvas, so `var(--…)` inputs resolve. */ resolveColor: (color: string) => string diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index aab6d2990152..59fc9d715cf1 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -3,7 +3,6 @@ import type React from 'react' import type { ChartMargins, ChartTheme, TooltipContext } from '../../core/types' import type { SankeyLinkDatum, SankeyLinkInput, SankeyNodeAlign, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' -/** What the cursor is over, with the laid-out datum attached. */ export type SankeyTooltipHit = | { kind: 'node'; node: SankeyNodeDatum } | { kind: 'link'; link: SankeyLinkDatum } @@ -28,7 +27,7 @@ export interface SankeyChartConfig { preserveNodeOrder?: boolean /** Header text for each column, left to right. Reserves room above the plot. */ columnLabels?: string[] - /** Draw each node's label beside it. Defaults to true. */ + /** Defaults to true. */ showNodeLabels?: boolean /** Append the node's value to its label. Defaults to false. */ showNodeValues?: boolean @@ -52,12 +51,10 @@ export interface SankeyChartProps { links: SankeyLinkInput[] theme: ChartTheme config?: SankeyChartConfig - /** Replaces the default tooltip content. */ tooltip?: (ctx: SankeyTooltipContext) => React.ReactNode onNodeClick?: (node: SankeyNodeDatum) => void onLinkClick?: (link: SankeyLinkDatum) => void className?: string - /** `data-attr` applied to the chart wrapper. */ dataAttr?: string /** Overlays rendered above the canvas. Read the layout with `useSankeyLayout()`. */ children?: React.ReactNode From 1954930b2127ced453e8ebf94089b47f906b7c8c Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 00:03:10 +0100 Subject: [PATCH 28/80] chore(quill-charts): cover transparent sankey nodes in the hover pass Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/draw-sankey.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index 4cf11434ba99..3c593d1547a8 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -20,9 +20,14 @@ function layoutOf(): ReturnType { }) } -/** Records stroke colors. Like a browser canvas it only accepts colors it can parse. */ -function recordingCtx(serialize: Record): { ctx: CanvasRenderingContext2D; strokes: string[] } { +/** Records stroke colors and filled rects. Like a browser canvas it only accepts colors it can parse. */ +function recordingCtx(serialize: Record): { + ctx: CanvasRenderingContext2D + strokes: string[] + fills: string[] +} { const strokes: string[] = [] + const fills: string[] = [] let fill = '#000000' let stroke = '#000000' const ctx = { @@ -45,10 +50,10 @@ function recordingCtx(serialize: Record): { ctx: CanvasRendering bezierCurveTo: () => {}, save: () => {}, restore: () => {}, - fillRect: () => {}, + fillRect: () => fills.push(fill), stroke: () => strokes.push(stroke), } as unknown as CanvasRenderingContext2D - return { ctx, strokes } + return { ctx, strokes, fills } } describe('drawSankeyHover', () => { @@ -74,10 +79,11 @@ describe('drawSankeyHover', () => { } ) - it('leaves a transparent ribbon to the static layer instead of mixing NaN channels', () => { + it('leaves a transparent ribbon and node to the static layer instead of mixing NaN channels', () => { const layout = layoutOf() layout.links[1].color = 'transparent' - const { ctx, strokes } = recordingCtx({ [BACKGROUND]: '#202023' }) + layout.nodes.find((node) => node.id === 'c')!.color = 'transparent' + const { ctx, strokes, fills } = recordingCtx({ [BACKGROUND]: '#202023' }) drawSankeyHover(ctx, layout, sankeyActiveFlow(layout, { kind: 'link', index: 0 }), { linkOpacity: 0.4, backgroundColor: BACKGROUND, @@ -85,5 +91,6 @@ describe('drawSankeyHover', () => { }) expect(strokes).toHaveLength(1) + expect(fills).toHaveLength(2) }) }) From 0c628a03c18711e43bcc67578b9db3e1d265dcf8 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 00:06:11 +0100 Subject: [PATCH 29/80] refactor(quill-charts): share tap tracking between chart interaction hooks The cartesian and Sankey interaction hooks each kept the same pointer type and tap-down tooltip refs. A shared useTapTracking hook now owns them, which clears the duplicated block the CI report flags. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SankeyChart/useSankeyInteraction.ts | 34 +++++++++---------- .../src/core/hooks/useChartInteraction.ts | 24 +++---------- .../charts/src/core/hooks/useTapTracking.ts | 32 +++++++++++++++++ 3 files changed, 53 insertions(+), 37 deletions(-) create mode 100644 packages/quill/packages/charts/src/core/hooks/useTapTracking.ts diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 65a2583c9ffc..77066c9e06eb 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -1,7 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react' +import React, { useCallback, useEffect, useMemo } from 'react' import { originatesInInteractiveOverlay } from '../../core/hooks/useChartInteraction' import { useLatest } from '../../core/hooks/useLatest' +import { useTapTracking } from '../../core/hooks/useTapTracking' import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' import type { Series, TooltipContext } from '../../core/types' import { hitToHoverIndex, hoverIndexToHit, sankeyHitAt } from './sankey-data' @@ -118,7 +119,6 @@ export function useSankeyInteraction({ }, [showTooltip, setTooltipCtx]) const hoverIndexRef = useLatest(hoverIndex) - const tooltipCtxRef = useLatest(tooltipCtx) const showHit = useCallback( (hit: SankeyHit, cursor: { x: number; y: number }) => { @@ -155,21 +155,9 @@ export function useSankeyInteraction({ clearTooltip() }, [clearTooltip]) - // Touch devices fire no mousemove before a tap, so the click has to resolve what was tapped - // itself. As on the cartesian charts, the first tap on a node or ribbon shows its tooltip and - // only a tap on the element already showing one fires the click handler. Both refs are read - // at pointerdown because a tap's compatibility mouse events arrive after pointerup. - const lastPointerTypeRef = useRef('mouse') - const tapDownTooltipIndexRef = useRef(-1) - - const onPointerDown = useCallback( - (e: React.PointerEvent) => { - lastPointerTypeRef.current = e.pointerType - // The visible tooltip, not the hover: hover can outlive a tooltip that was just hidden. - tapDownTooltipIndexRef.current = tooltipCtxRef.current?.dataIndex ?? -1 - }, - [tooltipCtxRef] - ) + // As on the cartesian charts, the first tap on a node or ribbon shows its tooltip and only a + // tap on the element already showing one fires the click handler. + const { lastPointerTypeRef, tapDownTooltipIndexRef, onPointerDown } = useTapTracking(tooltipCtx) const onClick = useCallback( (e: React.MouseEvent) => { @@ -203,7 +191,17 @@ export function useSankeyInteraction({ onLinkClick?.(resolved.link) } }, - [layoutRef, hoverIndexRef, clearTooltip, showHit, showTooltip, onNodeClick, onLinkClick] + [ + layoutRef, + hoverIndexRef, + lastPointerTypeRef, + tapDownTooltipIndexRef, + clearTooltip, + showHit, + showTooltip, + onNodeClick, + onLinkClick, + ] ) const handlers = useMemo( diff --git a/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts b/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts index 81c2a3dec981..1908b8c238ee 100644 --- a/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts +++ b/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react' +import React, { useCallback, useMemo } from 'react' import { findClosestSeriesKey } from '../../overlays/tooltipUtils' import { @@ -22,6 +22,7 @@ import type { } from '../types' import { useDragToZoom } from './useDragToZoom' import { useLatest } from './useLatest' +import { useTapTracking } from './useTapTracking' import { useTooltipLifecycle } from './useTooltipLifecycle' function originatesInElement(e: React.SyntheticEvent, selector: string): boolean { @@ -353,24 +354,7 @@ export function useChartInteraction({ clearTooltip() }, [isPinned, clearTooltip]) - // Touch support state. Touch devices fire no mousemove before a tap, so hover state is - // absent (or stale) when the tap's click arrives; the click handler must resolve the tapped - // point itself. `lastPointerTypeRef` tells it whether the click came from a touch, and - // `tapDownTooltipIndexRef` records which point's tooltip was showing when the gesture - // started. Both are captured at pointerdown because a tap's compatibility mouse events - // (mouseover/mousemove/mousedown) fire after pointerup, which means by click time the - // tooltip state may already reflect this very tap. - const tooltipCtxRef = useLatest(tooltipCtx) - const lastPointerTypeRef = useRef('mouse') - const tapDownTooltipIndexRef = useRef(-1) - - const onPointerDown = useCallback( - (e: React.PointerEvent) => { - lastPointerTypeRef.current = e.pointerType - tapDownTooltipIndexRef.current = tooltipCtxRef.current?.dataIndex ?? -1 - }, - [tooltipCtxRef] - ) + const { lastPointerTypeRef, tapDownTooltipIndexRef, onPointerDown } = useTapTracking(tooltipCtx) const onClick = useCallback( (e: React.MouseEvent) => { @@ -489,6 +473,8 @@ export function useChartInteraction({ unpin, shouldSwallowClick, hoverIndexRef, + lastPointerTypeRef, + tapDownTooltipIndexRef, hoverPositionRef, wrapClickData, scales, diff --git a/packages/quill/packages/charts/src/core/hooks/useTapTracking.ts b/packages/quill/packages/charts/src/core/hooks/useTapTracking.ts new file mode 100644 index 000000000000..40b09e24c5a8 --- /dev/null +++ b/packages/quill/packages/charts/src/core/hooks/useTapTracking.ts @@ -0,0 +1,32 @@ +import type { MutableRefObject } from 'react' +import React, { useCallback, useRef } from 'react' + +import { useLatest } from './useLatest' + +export interface TapTracking { + lastPointerTypeRef: MutableRefObject + /** The `dataIndex` of the tooltip that was visible when the gesture started, or -1. */ + tapDownTooltipIndexRef: MutableRefObject + onPointerDown: (e: React.PointerEvent) => void +} + +// Touch devices fire no mousemove before a tap, so a click handler has to resolve the tapped +// point itself and needs to know whether the click came from a touch. Both refs are captured at +// pointerdown because a tap's compatibility mouse events fire after pointerup, so by click time +// the tooltip may already reflect this very tap. Read the visible tooltip, not the hover: hover +// can outlive a tooltip that was just hidden. +export function useTapTracking(tooltipCtx: { dataIndex: number } | null): TapTracking { + const tooltipCtxRef = useLatest(tooltipCtx) + const lastPointerTypeRef = useRef('mouse') + const tapDownTooltipIndexRef = useRef(-1) + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + lastPointerTypeRef.current = e.pointerType + tapDownTooltipIndexRef.current = tooltipCtxRef.current?.dataIndex ?? -1 + }, + [tooltipCtxRef] + ) + + return { lastPointerTypeRef, tapDownTooltipIndexRef, onPointerDown } +} From 68d767689d42573e92eaaef61a47fbb169b82cef Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 13:36:49 +0100 Subject: [PATCH 30/80] fix(quill-charts): skip zero-valued sankey links, fall back on an empty palette Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/SankeyChart.tsx | 7 +++++-- .../charts/src/charts/SankeyChart/draw-sankey.ts | 4 ++++ .../src/charts/SankeyChart/sankey-data.test.ts | 14 ++++++++++++++ .../charts/src/charts/SankeyChart/sankey-data.ts | 3 +++ packages/quill/packages/charts/src/core/types.ts | 2 +- .../quill/packages/charts/src/docs/chart-types.md | 1 + 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 08797c1a5d91..d73d55dfe484 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -48,6 +48,9 @@ function graphKey( ]) } +/** Used when `theme.colors` is empty, as `Heatmap` does for its accent. */ +const FALLBACK_NODE_COLOR = '#1d4aff' + export function SankeyChart({ onError, ...rest @@ -108,10 +111,10 @@ function SankeyChartInner({ } const label = node.label ?? node.id if (!slots.has(label)) { - slots.set(label, theme.colors[slots.size % theme.colors.length]) + slots.set(label, theme.colors[slots.size % theme.colors.length] || FALLBACK_NODE_COLOR) } } - return (label: string): string => slots.get(label) ?? theme.colors[0] + return (label: string): string => slots.get(label) ?? (theme.colors[0] || FALLBACK_NODE_COLOR) }, [nodes, theme.colors]) const layout = useMemo>( diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index a1a33c551b5b..752dd89b94d5 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -25,6 +25,10 @@ function traceLink(ctx: CanvasRenderingContext2D, link: SankeyLinkDatum): void { } function strokeLink(ctx: CanvasRenderingContext2D, link: SankeyLinkDatum, color: string, opacity: number): void { + // A zero-valued link carries no flow, so the 1px floor below must not draw it. + if (link.value === 0) { + return + } traceLink(ctx, link) ctx.save() ctx.globalAlpha *= opacity diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 3cdd50bad20c..365b54aa71b4 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -143,6 +143,20 @@ describe('sankeyHitAt', () => { expect(sankeyHitAt(thin, cursor)).toEqual({ kind: 'link', index }) }) + it('does not hit a zero-value ribbon', () => { + const withZero = layoutOf({ + nodes: [{ id: 'start' }, { id: 'a' }, { id: 'b' }], + links: [ + { source: 'start', target: 'a', value: 30 }, + { source: 'start', target: 'b', value: 0 }, + ], + }) + const index = withZero.links.findIndex((l) => l.target.id === 'b') + const link = withZero.links[index] + const cursor = { x: (link.source.x1 + link.target.x0) / 2, y: (link.y0 + link.y1) / 2 } + expect(sankeyHitAt(withZero, cursor)).toBeNull() + }) + it('hits a zero-value node across the 1px the draw code floors it to', () => { const withOrphan = layoutOf({ nodes: [...NODES, { id: 'orphan' }], links: LINKS }) const orphan = withOrphan.nodes.find((n) => n.id === 'orphan')! diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index d5f9d08c1e46..ff554f0b579c 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -295,6 +295,9 @@ export function sankeyHitAt( let best: { index: number; distance: number } | null = null for (let i = 0; i < layout.links.length; i++) { const link = layout.links[i] + if (link.value === 0) { + continue + } const startX = link.source.x1 const endX = link.target.x0 if (cursor.x < startX || cursor.x > endX || endX <= startX) { diff --git a/packages/quill/packages/charts/src/core/types.ts b/packages/quill/packages/charts/src/core/types.ts index 5afdfe1166f5..cbbbfbdf5302 100644 --- a/packages/quill/packages/charts/src/core/types.ts +++ b/packages/quill/packages/charts/src/core/types.ts @@ -48,7 +48,7 @@ export interface Series { data: number[] /** CSS color string (hex, rgb) for the line and associated fill/points. Line, area, and bar * series hand it to the canvas unresolved, so resolve `var(--…)` in the host first; only - * `Heatmap`, `ScatterChart`, and `SankeyChart` resolve it themselves. When omitted (or empty), the chart + * `Heatmap` and `ScatterChart` resolve it themselves. When omitted (or empty), the chart * picks a color from `theme.colors` by series index. */ color?: string /** Bar charts only: per-bar overrides of the series-level `color`/`label`/`meta`, indexed by diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 7b92a94bbfb5..f93bd8cf2347 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -97,6 +97,7 @@ There is no `series` or `labels`. - Custom overlays read `useSankeyLayout()` for the positioned `nodes`, `links`, `columnX`, and `total`. - The layout engine ships on its own as `sankeyLayout` (plus the `sankeyLeft` / `sankeyJustify` alignments and `sankeyLinkHorizontal`) for hosts that draw their own SVG. It does not validate its input: the checks above run in `computeSankeyLayout`, so a host that calls the engine directly validates its own graph. + Links resolve their endpoints by array index by default; for links that name string node ids, call `sankeyLayout().nodeId((node) => node.id)` first. ## Sparkline From 84ee30f29713af6444000b4bb26ec26555bfc635 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 13:53:07 +0100 Subject: [PATCH 31/80] chore(quill-charts): cover zero-valued ribbons in the sankey hover pass Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/draw-sankey.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index 3c593d1547a8..d0841cf27f3b 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -3,12 +3,12 @@ import { computeSankeyLayout } from './sankey-data' const BACKGROUND = 'hsl(235deg 8% 15%)' -function layoutOf(): ReturnType { +function layoutOf(secondLinkValue = 10): ReturnType { return computeSankeyLayout({ nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], links: [ { source: 'a', target: 'b', value: 10 }, - { source: 'a', target: 'c', value: 10 }, + { source: 'a', target: 'c', value: secondLinkValue }, ], plot: { plotLeft: 0, plotTop: 0, plotWidth: 600, plotHeight: 300 }, nodeWidth: 10, @@ -58,12 +58,13 @@ function recordingCtx(serialize: Record): { describe('drawSankeyHover', () => { it.each([ - ['serialized by the canvas', { [BACKGROUND]: '#202023' }, 2], - ['rejected by the canvas', {}, 1], + ['serialized by the canvas', { [BACKGROUND]: '#202023' }, 10, 2], + ['rejected by the canvas', {}, 10, 1], + ['zero-valued inactive ribbon', { [BACKGROUND]: '#202023' }, 0, 1], ])( - 'never paints inactive ribbons in a background d3-color cannot parse: %s', - (_name, serialize, expectedStrokes) => { - const layout = layoutOf() + 'dims an inactive ribbon only when it carries flow and the background parses: %s', + (_name, serialize, secondLinkValue, expectedStrokes) => { + const layout = layoutOf(secondLinkValue) const { ctx, strokes } = recordingCtx(serialize) drawSankeyHover(ctx, layout, sankeyActiveFlow(layout, { kind: 'link', index: 0 }), { linkOpacity: 0.4, From a325de2ab813c80e176b4b0bf2d28e9ab9c0c693 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:41:58 +0000 Subject: [PATCH 32/80] chore(visual): update storybook baselines 8 updated Run: 3a9a2173-5b3b-4077-aae6-67b72f206701 Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- frontend/snapshots.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 4d146ff5c5aa..a937ae4607db 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -204,6 +204,22 @@ snapshots: hash: v1.k794b7964.8e7770037014d788ef5b7299b6b2f834c2afcad14dd6c6033ddd8ac885faa1c6.buSflXKIcQC5dcm7fbQWsczrZY8TleOkvO86ugYW8yw charts-heatmap--linear-color-scale--light: hash: v1.k794b7964.c94c02704b7f9b4972f811336d019e15b77cff047551902ca74d65c1571ea6ff.PA6k_wyPYr-SNYm1-Mv8sa0DFCLb2-8QxOVNlDn4hE8 + charts-sankeychart--hovered-link--dark: + hash: v1.k794b7964.e50f64b0a2556e240047944407ec800fb78ffc2ad3eca93219f235d64883327d.X8189N1MHu1yKO4xcQgIglRR8DdqCLFhNfcqHuspBNE + charts-sankeychart--hovered-link--light: + hash: v1.k794b7964.3d80ddb75942d750e8966a762b6605b9780e848478da52fbfc853336f1e1602f._wg3_fRbuh-eGfRO8WO8V6touEXs3l1g1ReCIzOYp7o + charts-sankeychart--left-aligned-with-values--dark: + hash: v1.k794b7964.1feb41fa4c6017593646eff1e33c11eb8ef9e224e5776a2bca43c97ff2390f9b.OQ83mEQIk1BTINe94sYI5ByoXnLHMvRo8W_9an60lrI + 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 + charts-sankeychart--narrow--light: + hash: v1.k794b7964.1f36bc77ab15bef2f717ba801cb66cb03cfe83d0deedffc0e69b0448302bb56c.1uAAAcF-jsEeS7JkUg0VbDHHQ1YZAdpwXiMG_8X9TRM + charts-sankeychart--tool-call-journeys--dark: + hash: v1.k794b7964.e50f64b0a2556e240047944407ec800fb78ffc2ad3eca93219f235d64883327d.9_vvmCGYMQ1aMSvgEOizcAf1tRGe7r0-FwNnrD3cHMo + charts-sankeychart--tool-call-journeys--light: + hash: v1.k794b7964.3d80ddb75942d750e8966a762b6605b9780e848478da52fbfc853336f1e1602f.-Pdh9g0MEEZjcHHMAB5swPyzQpd8gsqrUd6SN3sCM1w charts-scatterchart--best-fit--dark: hash: v1.k794b7964.0e422af020ee282c07fb513987159483cf8bc1ae701a576ba4e224f7d97bf355.f3zUaa87P5UbRRWD0X8BsZv-kDBKL422D_fxJJpUUd0 charts-scatterchart--best-fit--light: From 3b95dc71a3c59048dc778d35480a8947822aa795 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 22 Sep 2026 18:07:24 +0100 Subject: [PATCH 33/80] feat(paths): render user paths with the quill SankeyChart behind a flag Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- frontend/src/lib/constants.tsx | 1 + .../SankeyChart/SankeyChart.stories.tsx | 24 +++ .../charts/SankeyChart/SankeyChart.test.tsx | 22 ++- .../src/charts/SankeyChart/SankeyChart.tsx | 48 +++-- .../src/charts/SankeyChart/draw-sankey.ts | 147 ++++++++++----- .../charts/SankeyChart/sankey-data.test.ts | 2 + .../src/charts/SankeyChart/sankey-data.ts | 8 +- .../charts/src/charts/SankeyChart/types.ts | 14 ++ .../SankeyChart/useSankeyInteraction.ts | 43 ++++- .../packages/charts/src/core/dom-events.ts | 13 ++ .../src/core/hooks/useChartInteraction.ts | 14 +- .../packages/charts/src/docs/chart-types.md | 4 + packages/quill/packages/charts/src/index.ts | 1 + .../frontend/insights/paths/PathNodeCard.tsx | 5 +- .../frontend/insights/paths/PathNodeCards.tsx | 47 +++++ .../frontend/insights/paths/Paths.stories.tsx | 8 + .../frontend/insights/paths/Paths.tsx | 173 +----------------- .../frontend/insights/paths/PathsChart.tsx | 154 ++++++++++++++++ .../insights/paths/PathsColumnDividers.tsx | 22 +++ .../frontend/insights/paths/PathsDropoffs.tsx | 43 +++++ .../frontend/insights/paths/PathsLegacy.tsx | 170 +++++++++++++++++ .../frontend/insights/paths/constants.ts | 3 + .../frontend/insights/paths/pathUtils.ts | 61 ++++-- .../insights/paths/pathsChartData.test.ts | 89 +++++++++ .../frontend/insights/paths/pathsChartData.ts | 103 +++++++++++ .../frontend/insights/paths/renderPaths.ts | 15 +- 26 files changed, 952 insertions(+), 282 deletions(-) create mode 100644 packages/quill/packages/charts/src/core/dom-events.ts create mode 100644 products/product_analytics/frontend/insights/paths/PathNodeCards.tsx create mode 100644 products/product_analytics/frontend/insights/paths/PathsChart.tsx create mode 100644 products/product_analytics/frontend/insights/paths/PathsColumnDividers.tsx create mode 100644 products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx create mode 100644 products/product_analytics/frontend/insights/paths/PathsLegacy.tsx create mode 100644 products/product_analytics/frontend/insights/paths/pathsChartData.test.ts create mode 100644 products/product_analytics/frontend/insights/paths/pathsChartData.ts diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 8c1d24f74eb4..5f3a8f81097f 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -482,6 +482,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 diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx index 389f893661a0..61a306015bcb 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.stories.tsx @@ -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 ( + + + + ) + }, +} + export const Narrow: Story = { render: function Render() { const theme = useReactiveTheme() diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 3a4207faa6fc..51351408d4a8 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -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( - , + , { nativeTooltip: true } ) await waitFor(() => { @@ -65,6 +72,16 @@ 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') + }) onNodeClick.mockClear() rerender( { links={LINKS} theme={THEME} onNodeClick={onNodeClick} + onHoverChange={onHoverChange} config={{ nodeWidth: 24 }} /> ) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index d73d55dfe484..e3dc5d3530d7 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -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' @@ -70,6 +70,8 @@ function SankeyChartInner({ tooltip, onNodeClick, onLinkClick, + onHoverChange, + highlight, className, dataAttr, children, @@ -146,32 +148,46 @@ function SankeyChartInner({ showTooltip, onNodeClick, onLinkClick, + onHoverChange, }) + // 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, highlight) : null), + [layout, highlight] + ) const drawStatic = useCallback( - ({ ctx: drawCtx }: ChartDrawArgs) => - drawSankey(drawCtx, layout as SankeyChartLayout, { linkOpacity }), - [layout, linkOpacity] + ({ ctx: drawCtx, theme: drawTheme }: ChartDrawArgs) => + drawSankey(drawCtx, layout as SankeyChartLayout, { + 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 - 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, activeFlowFor(index), { - linkOpacity, - backgroundColor: drawTheme.backgroundColor, - progress: hoverProgress, - }), - [layout, linkOpacity, activeFlowFor] + emphasis + ? false + : drawSankeyHover(drawCtx, layout as SankeyChartLayout, hoverEmphasisFor(index), { + linkOpacity, + backgroundColor: drawTheme.backgroundColor, + progress: hoverProgress, + }), + [layout, linkOpacity, emphasis, hoverEmphasisFor] ) useChartDraw({ diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index 752dd89b94d5..e15975c886b6 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -2,11 +2,20 @@ import { color as d3Color } from 'd3-color' import { mixColors } from '../../core/color-utils' import type { SankeyChartLayout, SankeyHit, SankeyLinkDatum, SankeyNodeDatum } from './sankey-data' +import type { SankeyHighlight } from './types' export interface DrawSankeyOptions { linkOpacity: number } +/** The part of the graph that stays at full strength while the rest dims. */ +export interface SankeyEmphasis { + nodes: Set + links: Set + /** The node under the cursor, lightened a step further than the other active nodes. */ + focus?: SankeyNodeDatum +} + /** Ribbon opacity while it, or a node it touches, is hovered. */ const HOVER_LINK_OPACITY = 0.85 /** The rest of the graph fades toward the background so the hovered flow stands out. */ @@ -44,24 +53,8 @@ function fillNode(ctx: CanvasRenderingContext2D, node: SankeyNodeDatum, color: s ctx.fillRect(node.x0, node.y0, node.x1 - node.x0, Math.max(1, node.y1 - node.y0)) } -/** Static layer: every ribbon under every node, in input order. */ -export function drawSankey(ctx: CanvasRenderingContext2D, layout: SankeyChartLayout, options: DrawSankeyOptions): void { - for (const link of layout.links) { - strokeLink(ctx, link, link.color, options.linkOpacity) - } - for (const node of layout.nodes) { - fillNode(ctx, node, node.color) - } -} - -export interface SankeyActiveFlow { - hit: SankeyHit - links: Set - nodes: Set -} - -/** The hovered node or ribbon plus everything connected to it, or `null` when nothing is hovered. */ -export function sankeyActiveFlow(layout: SankeyChartLayout, hit: SankeyHit | null): SankeyActiveFlow | null { +/** The hovered node with every ribbon it touches, or the hovered ribbon with its two ends. */ +export function emphasisForHit(layout: SankeyChartLayout, hit: SankeyHit | null): SankeyEmphasis | null { if (!hit) { return null } @@ -77,13 +70,32 @@ export function sankeyActiveFlow(layout: SankeyChartLayout, hit: SankeyHit | nul nodes.add(link.target) } } - } else { - const link = layout.links[hit.index] - links.add(link) - nodes.add(link.source) - nodes.add(link.target) + return { nodes, links, focus: node } } - return { hit, links, nodes } + const link = layout.links[hit.index] + links.add(link) + nodes.add(link.source) + nodes.add(link.target) + return { nodes, links } +} + +/** Resolves a consumer's highlight to laid-out data. Ids and indices the layout does not know are + * ignored, so a highlight computed from a previous dataset does not throw mid-transition. */ +export function emphasisForHighlight(layout: SankeyChartLayout, highlight: SankeyHighlight): SankeyEmphasis { + const nodes = new Set() + const links = new Set() + for (const node of layout.nodes) { + if (highlight.nodeIds.has(node.id)) { + nodes.add(node) + } + } + for (const index of highlight.linkIndices) { + const link = layout.links[index] + if (link) { + links.add(link) + } + } + return { nodes, links } } function isMixable(parsed: ReturnType | null): boolean { @@ -111,55 +123,92 @@ function parseableColor(ctx: CanvasRenderingContext2D, color: string): string | return first === second && isMixable(d3Color(first)) ? first : null } -/** Hover layer: dims the graph toward the background, then repaints the active flow at full - * strength. Returns false when nothing is hovered. */ -export function drawSankeyHover( +/** Paints `emphasis` at full strength over a graph already drawn at rest, dimming the rest toward + * `dimTarget` as `progress` goes from 0 to 1. */ +function paintEmphasis( ctx: CanvasRenderingContext2D, layout: SankeyChartLayout, - flow: SankeyActiveFlow | null, - options: DrawSankeyOptions & { backgroundColor?: string; progress: number } -): boolean { - if (!flow) { - return false - } - const { hit, links: activeLinks, nodes: activeNodes } = flow - const dimTarget = parseableColor(ctx, options.backgroundColor || HOVER_DIM_TARGET_FALLBACK) + emphasis: SankeyEmphasis, + options: DrawSankeyOptions & { dimTarget: string; progress: number } +): void { + const dimTarget = parseableColor(ctx, options.dimTarget) const dim = options.progress * HOVER_DIM_AMOUNT - - // The dim fill must be opaque: a translucent repaint composites over the full-color static - // layer and does not dim at all. A ribbon with nothing parseable to mix is left to the static - // layer: any repaint over it would either darken it or paint it in the background color. + // The dim fill must be opaque: a translucent repaint composites over the full-color resting + // layer and does not dim at all. A ribbon with nothing parseable to mix is left as drawn at + // rest: any repaint over it would either darken it or paint it in the background color. for (const link of layout.links) { - if (activeLinks.has(link) || !dimTarget) { + if (emphasis.links.has(link) || !dimTarget) { continue } const linkColor = parseableColor(ctx, link.color) if (linkColor) { const resting = mixColors(dimTarget, linkColor, options.linkOpacity) - // Fading the opaque dim in with progress keeps the first frame identical to the static layer. + // Fading the opaque dim in with progress keeps the first frame identical to the resting layer. strokeLink(ctx, link, mixColors(resting, dimTarget, dim), options.progress) } } - for (const link of activeLinks) { - const opacity = options.linkOpacity + (HOVER_LINK_OPACITY - options.linkOpacity) * options.progress - strokeLink(ctx, link, link.color, opacity) + // A resting opacity above the hover target must not drop when the flow is emphasized. + const targetOpacity = Math.max(HOVER_LINK_OPACITY, options.linkOpacity) + const activeOpacity = options.linkOpacity + (targetOpacity - options.linkOpacity) * options.progress + for (const link of emphasis.links) { + strokeLink(ctx, link, link.color, activeOpacity) } - // Nodes go last so a ribbon that skips a column never paints over the node it passes behind. + // Every ribbon goes under every node, as on the resting layer, so an emphasized ribbon that + // crosses a column does not cover a node it does not touch. for (const node of layout.nodes) { - if (!activeNodes.has(node) && dimTarget) { + if (!emphasis.nodes.has(node) && dimTarget) { const nodeColor = parseableColor(ctx, node.color) if (nodeColor) { fillNode(ctx, node, mixColors(nodeColor, dimTarget, dim)) } } } - for (const node of activeNodes) { - const highlight = hit.kind === 'node' && node === layout.nodes[hit.index] - const highlightBase = highlight ? parseableColor(ctx, node.color) : null + for (const node of emphasis.nodes) { + const highlightBase = node === emphasis.focus ? parseableColor(ctx, node.color) : null const color = highlightBase ? mixColors(highlightBase, HOVER_HIGHLIGHT_TARGET, HOVER_HIGHLIGHT_AMOUNT * options.progress) : node.color fillNode(ctx, node, color) } +} + +/** Static layer: every ribbon under every node, in input order. With `emphasis`, the graph is + * painted already dimmed around that set, which is how a controlled highlight shows. */ +export function drawSankey( + ctx: CanvasRenderingContext2D, + layout: SankeyChartLayout, + options: DrawSankeyOptions & { emphasis?: SankeyEmphasis | null; backgroundColor?: string } +): void { + for (const link of layout.links) { + strokeLink(ctx, link, link.color, options.linkOpacity) + } + for (const node of layout.nodes) { + fillNode(ctx, node, node.color) + } + if (options.emphasis) { + paintEmphasis(ctx, layout, options.emphasis, { + linkOpacity: options.linkOpacity, + dimTarget: options.backgroundColor || HOVER_DIM_TARGET_FALLBACK, + progress: 1, + }) + } +} + +/** Hover layer: dims the graph toward the background, then repaints the hovered flow (from + * `emphasisForHit`) at full strength. Returns false when nothing is hovered. */ +export function drawSankeyHover( + ctx: CanvasRenderingContext2D, + layout: SankeyChartLayout, + emphasis: SankeyEmphasis | null, + options: DrawSankeyOptions & { backgroundColor?: string; progress: number } +): boolean { + if (!emphasis) { + return false + } + paintEmphasis(ctx, layout, emphasis, { + linkOpacity: options.linkOpacity, + dimTarget: options.backgroundColor || HOVER_DIM_TARGET_FALLBACK, + progress: options.progress, + }) return true } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 365b54aa71b4..9ebcdc58ff17 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -46,6 +46,8 @@ describe('computeSankeyLayout', () => { expect(byId.done.value).toBe(40) // The total is the inflow of the source nodes, which is what shares are measured against. expect(layout.total).toBe(40) + // `SankeyHighlight.linkIndices` and click handlers address links by input position. + expect(layout.links.map((l) => [l.source.id, l.target.id])).toEqual(LINKS.map((l) => [l.source, l.target])) }) it('places a flow that ends early by alignment: justify, left, and right', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index ff554f0b579c..da5893194b0c 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -34,6 +34,8 @@ export interface SankeyNodeDatum { label: string color: string meta?: Meta + /** Position in the `nodes` prop and in `layout.nodes`. */ + index: number column: number /** Sum of the larger side's link values (in or out). */ value: number @@ -51,6 +53,8 @@ export interface SankeyLinkDatum { value: number color: string meta?: LinkMeta + /** Position in the `links` prop and in `layout.links`; what `SankeyHighlight.linkIndices` names. */ + index: number y0: number y1: number width: number @@ -233,6 +237,7 @@ export function computeSankeyLayout({ label: node.label, color: node.color, meta: node.meta as NodeMeta | undefined, + index: node.index, column: node.layer, value: node.value, x0: node.x0, @@ -244,7 +249,7 @@ export function computeSankeyLayout({ return datum }) - const outLinks = graph.links.map((link): SankeyLinkDatum => { + const outLinks = graph.links.map((link, index): SankeyLinkDatum => { const source = datumByIndex.get(link.source.index)! const target = datumByIndex.get(link.target.index)! return { @@ -253,6 +258,7 @@ export function computeSankeyLayout({ value: link.value, color: link.color ? resolveColor(link.color) : source.color, meta: link.meta as LinkMeta | undefined, + index, y0: link.y0, y1: link.y1, width: link.width, diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index 59fc9d715cf1..1da5c9b27243 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -14,6 +14,13 @@ export type SankeyTooltipContext = Tool total: number } +/** A consumer-chosen part of the graph to keep at full strength while the rest dims. Nodes are + * named by id; links by their index in the `links` prop, which the layout keeps. */ +export interface SankeyHighlight { + nodeIds: ReadonlySet + linkIndices: ReadonlySet +} + export interface SankeyChartConfig { /** Node rectangle width in px. Defaults to 12. */ nodeWidth?: number @@ -54,6 +61,13 @@ export interface SankeyChartProps { tooltip?: (ctx: SankeyTooltipContext) => React.ReactNode onNodeClick?: (node: SankeyNodeDatum) => void onLinkClick?: (link: SankeyLinkDatum) => void + /** Fires when the cursor moves onto a different node or ribbon, and with `null` when it leaves + * the graph. Lets a host drive its own emphasis, for example through `highlight`. */ + onHoverChange?: (hit: SankeyTooltipHit | null) => void + /** Controlled emphasis. While set, the chart dims everything outside this set and the built-in + * hover dimming is off, so the host decides what a hover means (the whole downstream path, a + * selection). Pass `null` to hand emphasis back to the hover. */ + highlight?: SankeyHighlight | null className?: string dataAttr?: string /** Overlays rendered above the canvas. Read the layout with `useSankeyLayout()`. */ diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 77066c9e06eb..2ffe3de1531b 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo } from 'react' -import { originatesInInteractiveOverlay } from '../../core/hooks/useChartInteraction' +import { originatesInInteractiveOverlay } from '../../core/dom-events' import { useLatest } from '../../core/hooks/useLatest' import { useTapTracking } from '../../core/hooks/useTapTracking' import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' @@ -16,6 +16,11 @@ interface UseSankeyInteractionOptions { showTooltip: boolean onNodeClick?: (node: SankeyNodeDatum) => void onLinkClick?: (link: SankeyLinkDatum) => void + onHoverChange?: (hit: SankeyTooltipHit | null) => void +} + +function hitKey(hit: SankeyHit | null): string | null { + return hit ? `${hit.kind}:${hit.index}` : null } interface UseSankeyInteractionResult { @@ -82,9 +87,25 @@ export function useSankeyInteraction({ showTooltip, onNodeClick, onLinkClick, + onHoverChange, }: UseSankeyInteractionOptions): UseSankeyInteractionResult { type Ctx = SankeyTooltipContext const layoutRef = useLatest(layout) + const onHoverChangeRef = useLatest(onHoverChange) + // The last hit reported to the host, so a cursor sweep inside one ribbon reports it once. + const reportedHitRef = useRef(null) + + const reportHover = useCallback( + (hit: SankeyHit | null) => { + const key = hitKey(hit) + if (key === reportedHitRef.current) { + return + } + reportedHitRef.current = key + onHoverChangeRef.current?.(hit ? resolveHit(layoutRef.current, hit) : null) + }, + [onHoverChangeRef, layoutRef] + ) const rebuildPinnedCtx = useCallback( (prev: TooltipContext): Ctx | null => { @@ -123,14 +144,15 @@ export function useSankeyInteraction({ const showHit = useCallback( (hit: SankeyHit, cursor: { x: number; y: number }) => { setHover(hitToHoverIndex(layoutRef.current, hit), cursor) - if (!showTooltip) { + if (showTooltip) { + const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() + setTooltipCtx(buildTooltipCtx(layoutRef.current, hit, cursor, canvasBounds)) + } else { setTooltipCtx(null) - return } - const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() - setTooltipCtx(buildTooltipCtx(layoutRef.current, hit, cursor, canvasBounds)) + reportHover(hit) }, - [layoutRef, showTooltip, setHover, setTooltipCtx, canvasRef] + [layoutRef, showTooltip, setHover, setTooltipCtx, canvasRef, reportHover] ) const onMouseMove = useCallback( @@ -139,11 +161,17 @@ export function useSankeyInteraction({ if (current.nodes.length === 0) { return } + if (originatesInInteractiveOverlay(e)) { + clearTooltip() + reportHover(null) + return + } const rect = e.currentTarget.getBoundingClientRect() const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } const hit = originatesInInteractiveOverlay(e) ? null : sankeyHitAt(current, cursor) if (!hit) { clearTooltip() + reportHover(null) return } showHit(hit, cursor) @@ -153,7 +181,8 @@ export function useSankeyInteraction({ const onMouseLeave = useCallback(() => { clearTooltip() - }, [clearTooltip]) + reportHover(null) + }, [clearTooltip, reportHover]) // As on the cartesian charts, the first tap on a node or ribbon shows its tooltip and only a // tap on the element already showing one fires the click handler. diff --git a/packages/quill/packages/charts/src/core/dom-events.ts b/packages/quill/packages/charts/src/core/dom-events.ts new file mode 100644 index 000000000000..bf49def408c9 --- /dev/null +++ b/packages/quill/packages/charts/src/core/dom-events.ts @@ -0,0 +1,13 @@ +import type React from 'react' + +export function originatesInElement(e: React.SyntheticEvent, selector: string): boolean { + return e.target instanceof Element && !!e.target.closest(selector) +} + +/** An interactive overlay child (a clickable marker, a node card) renders inside the same wrapper + * the chart's mousemove handler is bound to, so every hover over it still bubbles there. Without + * this guard the chart's own hover tracking fights the overlay for the cursor. An overlay opts out + * by marking its interactive root with this attribute. */ +export function originatesInInteractiveOverlay(e: React.SyntheticEvent): boolean { + return originatesInElement(e, '[data-hog-charts-interactive-overlay]') +} diff --git a/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts b/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts index 1908b8c238ee..dfe6aaf0f663 100644 --- a/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts +++ b/packages/quill/packages/charts/src/core/hooks/useChartInteraction.ts @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react' import { findClosestSeriesKey } from '../../overlays/tooltipUtils' +import { originatesInElement, originatesInInteractiveOverlay } from '../dom-events' import { buildLabelPositions, buildPointClickData, @@ -25,10 +26,6 @@ import { useLatest } from './useLatest' import { useTapTracking } from './useTapTracking' import { useTooltipLifecycle } from './useTooltipLifecycle' -function originatesInElement(e: React.SyntheticEvent, selector: string): boolean { - return e.target instanceof Element && !!e.target.closest(selector) -} - /** The tooltip is portaled out of the wrapper's DOM tree, but React portals still bubble * synthetic events through the React tree — so a click or drag that starts inside the pinned * tooltip reaches the wrapper's handlers and would dismiss the pin or start a zoom drag. */ @@ -36,15 +33,6 @@ function originatesInTooltip(e: React.SyntheticEvent): boolean { return originatesInElement(e, '[data-hog-charts-tooltip]') } -/** An interactive overlay child (e.g. a clickable exemplar marker) renders inside the same - * wrapper this hook's mousemove handler is bound to, so every hover over it still bubbles here. - * Without this guard the chart's own nearest-point tooltip fights the overlay child's tooltip - * for the cursor. An overlay opts out of chart hover tracking by marking its interactive root - * with this attribute. */ -export function originatesInInteractiveOverlay(e: React.SyntheticEvent): boolean { - return originatesInElement(e, '[data-hog-charts-interactive-overlay]') -} - interface UseChartInteractionOptions { scales: ChartScales | null dimensions: ChartDimensions | null diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index f93bd8cf2347..84fbd2369061 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -94,7 +94,11 @@ There is no `series` or `labels`. `onNodeClick` and `onLinkClick` receive the laid-out datum with its `meta`. `tooltip.placement` takes the cartesian charts' values and defaults to `cursor`. On touch, the first tap on a node or ribbon shows its tooltip and a second tap on the same one fires the click handler. +- `onHoverChange` reports the node or ribbon under the cursor, and `null` when it leaves. + `highlight` takes over emphasis: the chart dims everything outside `{ nodeIds, linkIndices }` and stops its own hover dimming, so a host can light up a whole downstream path or a selection. + Link indices are positions in the `links` prop; the layout keeps that order. - Custom overlays read `useSankeyLayout()` for the positioned `nodes`, `links`, `columnX`, and `total`. + An overlay with its own pointer handling marks its root with `data-hog-charts-interactive-overlay`, which also stops the chart from reporting a hover while the cursor is on it. - The layout engine ships on its own as `sankeyLayout` (plus the `sankeyLeft` / `sankeyJustify` alignments and `sankeyLinkHorizontal`) for hosts that draw their own SVG. It does not validate its input: the checks above run in `computeSankeyLayout`, so a host that calls the engine directly validates its own graph. Links resolve their endpoints by array index by default; for links that name string node ids, call `sankeyLayout().nodeId((node) => node.id)` first. diff --git a/packages/quill/packages/charts/src/index.ts b/packages/quill/packages/charts/src/index.ts index 03025f645b54..0cd03c9e8d3c 100644 --- a/packages/quill/packages/charts/src/index.ts +++ b/packages/quill/packages/charts/src/index.ts @@ -118,6 +118,7 @@ export { SankeyChart } from './charts/SankeyChart/SankeyChart' export type { SankeyChartConfig, SankeyChartProps, + SankeyHighlight, SankeyTooltipContext, SankeyTooltipHit, } from './charts/SankeyChart/types' diff --git a/products/product_analytics/frontend/insights/paths/PathNodeCard.tsx b/products/product_analytics/frontend/insights/paths/PathNodeCard.tsx index d0ac79e97599..708894668d47 100644 --- a/products/product_analytics/frontend/insights/paths/PathNodeCard.tsx +++ b/products/product_analytics/frontend/insights/paths/PathNodeCard.tsx @@ -93,7 +93,7 @@ export function PathNodeCard({ onMouseLeaveInside={onMouseLeave} >
diff --git a/products/product_analytics/frontend/insights/paths/PathNodeCards.tsx b/products/product_analytics/frontend/insights/paths/PathNodeCards.tsx new file mode 100644 index 000000000000..86596b266379 --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/PathNodeCards.tsx @@ -0,0 +1,47 @@ +import { useActions, useValues } from 'kea' +import { useEffect, useMemo } from 'react' + +import { useChartLayout, useSankeyLayout } from '@posthog/quill-charts' + +import { PathsLink } from '~/queries/schema/schema-general' +import { InsightLogicProps } from '~/types' + +import { PathNodeCard } from './PathNodeCard' +import { toPathNodeData } from './pathsChartData' +import { pathsInteractionLogic } from './pathsInteractionLogic' + +export interface PathNodeCardsProps { + insightProps: InsightLogicProps +} + +/** Chart overlay: hands the laid-out nodes to the interaction logic and renders one card per + * node the logic decides to show. */ +export function PathNodeCards({ insightProps }: PathNodeCardsProps): JSX.Element { + const { layout } = useSankeyLayout() + const { dimensions } = useChartLayout() + const interactionLogic = pathsInteractionLogic(insightProps) + const { resolvedNodeCards, cardPopoverIndex } = useValues(interactionLogic) + const { setNodes, hoverCard, unhoverCard } = useActions(interactionLogic) + + const nodes = useMemo(() => toPathNodeData(layout), [layout]) + const canvasHeight = dimensions.plotTop + dimensions.plotHeight + useEffect(() => { + setNodes(nodes, canvasHeight) + }, [nodes, canvasHeight, setNodes]) + + return ( + <> + {resolvedNodeCards.map((node) => ( + hoverCard(node.index)} + onMouseLeave={unhoverCard} + /> + ))} + + ) +} diff --git a/products/product_analytics/frontend/insights/paths/Paths.stories.tsx b/products/product_analytics/frontend/insights/paths/Paths.stories.tsx index 9a31eb6e6041..91848ec86338 100644 --- a/products/product_analytics/frontend/insights/paths/Paths.stories.tsx +++ b/products/product_analytics/frontend/insights/paths/Paths.stories.tsx @@ -1,6 +1,7 @@ import { Meta, StoryObj } from '@storybook/react' import { waitFor } from '@testing-library/react' +import { FEATURE_FLAGS } from 'lib/constants' import { createInsightStory, insightSceneMswDecorator, @@ -42,6 +43,13 @@ export const UserPaths: Story = { ), } +export const UserPathsSankeyChart: Story = { + ...UserPaths, + parameters: { + featureFlags: [FEATURE_FLAGS.PRODUCT_ANALYTICS_PATHS_SANKEY_CHART], + }, +} + // The Paths component uses useResizeObserver to measure canvasWidth/canvasHeight, then destroys // and recreates the SVG when they change (or when theme/data changes). Dimension updates are // debounced to reduce recreations. The canvas div gets data-stable removed during recreation diff --git a/products/product_analytics/frontend/insights/paths/Paths.tsx b/products/product_analytics/frontend/insights/paths/Paths.tsx index 46b977d96761..bc5dadced506 100644 --- a/products/product_analytics/frontend/insights/paths/Paths.tsx +++ b/products/product_analytics/frontend/insights/paths/Paths.tsx @@ -1,171 +1,12 @@ -import './Paths.scss' +import { useValues } from 'kea' -import { useActions, useValues } from 'kea' -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { useResizeObserver } from 'lib/hooks/useResizeObserver' -import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates' -import { insightDataLogic } from 'scenes/insights/insightDataLogic' -import { insightLogic } from 'scenes/insights/insightLogic' - -import { FunnelPathsFilter } from '~/queries/schema/schema-general' -import { shouldQueryBeAsync } from '~/queries/utils' - -import { PathNodeCard } from './PathNodeCard' -import { pathsDataLogic } from './pathsDataLogic' -import { pathsInteractionLogic } from './pathsInteractionLogic' -import type { PathNodeData } from './pathUtils' -import type { PathsHoverHandlers } from './renderPaths' -// eslint-disable-next-line import/no-cycle -import { renderPaths } from './renderPaths' - -const DEFAULT_PATHS_ID = 'default_paths' -export const HIDE_PATH_CARD_HEIGHT = 30 -export const FALLBACK_CANVAS_WIDTH = 1000 -const FALLBACK_CANVAS_HEIGHT = 0 -const CANVAS_RESIZE_DEBOUNCE_MS = 50 +import { PathsChart } from './PathsChart' +import { PathsLegacy } from './PathsLegacy' export function Paths(): JSX.Element { - const canvasRef = useRef(null) - const canvasContainerRef = useRef(null) - const { width: rawCanvasWidth, height: rawCanvasHeight } = useResizeObserver({ ref: canvasRef }) - const [canvasWidth, setCanvasWidth] = useState(FALLBACK_CANVAS_WIDTH) - const [canvasHeight, setCanvasHeight] = useState(FALLBACK_CANVAS_HEIGHT) - - // Debounce canvas dimension updates to prevent rapid SVG recreation from ResizeObserver. - // We do NOT remove data-stable here — the render effect below removes it only when the SVG - // is actually being recreated. Removing it here would cause data-stable to disappear for the - // entire debounce window on every resize, making Playwright's waitForSelector time out. - useEffect(() => { - const timer = setTimeout(() => { - setCanvasWidth(rawCanvasWidth ?? FALLBACK_CANVAS_WIDTH) - setCanvasHeight(rawCanvasHeight ?? FALLBACK_CANVAS_HEIGHT) - }, CANVAS_RESIZE_DEBOUNCE_MS) - return () => clearTimeout(timer) - }, [rawCanvasWidth, rawCanvasHeight]) - - const { insight, insightProps } = useValues(insightLogic) - const { insightQuery, paths, pathsFilter, funnelPathsFilter, insightDataLoading, insightDataError, theme } = - useValues(pathsDataLogic(insightProps)) - const { loadData } = useActions(insightDataLogic(insightProps)) - - const interactionLogic = pathsInteractionLogic(insightProps) - const { resolvedNodeCards, activeIndices, cardPopoverIndex } = useValues(interactionLogic) - const { setNodes, hoverNode, hoverLink, clearHover, requestClearHover, hoverCard, unhoverCard } = - useActions(interactionLogic) - - // The handlers must keep their identity for the lifetime of the component. They are a - // dependency of the render effect below, so a new object there tears the SVG out from under - // the pointer and clears the hover that had just been set. Anything that changes is read - // when the handler runs, not when it is built. - const canvasHeightRef = useRef(canvasHeight) - canvasHeightRef.current = canvasHeight - const hoverHandlers = useMemo( - () => ({ - onNodesReady: (nodes: PathNodeData[]) => setNodes(nodes, canvasHeightRef.current), - onNodeHover: hoverNode, - onLinkHover: hoverLink, - onHoverClear: requestClearHover, - isCardHovered: () => interactionLogic.values.cardHovered, - }), - [interactionLogic, setNodes, hoverNode, hoverLink, requestClearHover] - ) - - useLayoutEffect(() => { - canvasRef.current?.querySelectorAll('path[id^="path-"]').forEach((el) => { - const pathIndex = Number(el.id.replace('path-', '')) - el.setAttribute( - 'stroke', - activeIndices.linkIndices.has(pathIndex) ? 'var(--paths-link-hover)' : 'var(--paths-link)' - ) - }) - }, [activeIndices]) - - const id = `'${insight?.short_id || DEFAULT_PATHS_ID}'` - - useEffect(() => { - clearHover() - - const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`) - elements?.forEach((node) => node?.parentNode?.removeChild(node)) - - if (canvasRef.current) { - canvasRef.current.removeAttribute('data-stable') - } - - renderPaths( - canvasRef, - canvasWidth, - canvasHeight, - paths, - pathsFilter || {}, - funnelPathsFilter || ({} as FunnelPathsFilter), - hoverHandlers - ) - - if (canvasRef.current) { - canvasRef.current.setAttribute('data-stable', 'true') - } - - return () => { - const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`) - elements?.forEach((node) => node?.parentNode?.removeChild(node)) - } - }, [ - paths, - insightDataLoading, - canvasWidth, - canvasHeight, - theme, - pathsFilter, - funnelPathsFilter, - hoverHandlers, - clearHover, - ]) - - if (insightDataError) { - return ( - { - loadData(shouldQueryBeAsync(insightQuery) ? 'force_async' : 'force_blocking') - }} - /> - ) - } - - return ( -
-
- {!insightDataLoading && paths && paths.nodes.length === 0 && !insightDataError && } - {!insightDataError && - resolvedNodeCards.map((node, idx) => ( - hoverCard(node.index)} - onMouseLeave={unhoverCard} - /> - ))} -
-
- ) + const { featureFlags } = useValues(featureFlagLogic) + return featureFlags[FEATURE_FLAGS.PRODUCT_ANALYTICS_PATHS_SANKEY_CHART] ? : } diff --git a/products/product_analytics/frontend/insights/paths/PathsChart.tsx b/products/product_analytics/frontend/insights/paths/PathsChart.tsx new file mode 100644 index 000000000000..b0f1845dbc65 --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/PathsChart.tsx @@ -0,0 +1,154 @@ +import './Paths.scss' + +import { useActions, useValues } from 'kea' +import { useCallback, useMemo, useRef } from 'react' + +import { SankeyChart } from '@posthog/quill-charts' +import type { SankeyChartConfig, SankeyHighlight, SankeyTooltipHit } from '@posthog/quill-charts' + +import { useChartTheme } from 'lib/charts/hooks' +import { useResizeObserver } from 'lib/hooks/useResizeObserver' +import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates' +import { insightDataLogic } from 'scenes/insights/insightDataLogic' +import { insightLogic } from 'scenes/insights/insightLogic' + +import { PathsLink } from '~/queries/schema/schema-general' +import { shouldQueryBeAsync } from '~/queries/utils' + +import { FALLBACK_CANVAS_WIDTH } from './constants' +import { PathNodeCards } from './PathNodeCards' +import { buildPathsGraph, maxPathLayer, pathsChartWidth } from './pathsChartData' +import type { PathsGraphColors } from './pathsChartData' +import { PathsColumnDividers } from './PathsColumnDividers' +import { pathsDataLogic } from './pathsDataLogic' +import { PathsDropoffs } from './PathsDropoffs' +import { pathsInteractionLogic } from './pathsInteractionLogic' + +const DEFAULT_PATHS_ID = 'default_paths' +const FALLBACK_COLOR = '#000000' +const NO_MARGINS = { top: 0, right: 0, bottom: 0, left: 0 } + +// The cards carry every label and number, so the chart draws bare nodes and ribbons. Node order +// follows the result, as the SVG renderer did, so the two renderers agree on where a step sits. +const CHART_CONFIG: SankeyChartConfig = { + nodeWidth: 15, + nodePadding: 8, + nodeAlign: 'justify', + preserveNodeOrder: true, + showNodeLabels: false, + linkOpacity: 0.35, + tooltip: { enabled: false }, + margins: NO_MARGINS, +} + +/** The paths insight on the quill `SankeyChart`. The chart lays out and paints the graph; the + * cards, drop-offs, and hover emphasis stay with the paths logic through overlays. */ +export function PathsChart(): JSX.Element { + const containerRef = useRef(null) + const { width: containerWidth } = useResizeObserver({ ref: containerRef }) + + const { insight, insightProps } = useValues(insightLogic) + const { insightQuery, paths, pathsFilter, funnelPathsFilter, insightDataLoading, insightDataError, theme } = + useValues(pathsDataLogic(insightProps)) + const { loadData } = useActions(insightDataLogic(insightProps)) + const interactionLogic = pathsInteractionLogic(insightProps) + const { activeIndices, nodes } = useValues(interactionLogic) + const { hoverNode, hoverLink, requestClearHover } = useActions(interactionLogic) + const chartTheme = useChartTheme() + + const colors = useMemo( + () => ({ + node: theme?.['preset-1'] || FALLBACK_COLOR, + selectedNode: theme?.['preset-2'] || FALLBACK_COLOR, + link: theme?.['preset-1'] || FALLBACK_COLOR, + }), + [theme] + ) + const graph = useMemo( + () => buildPathsGraph(paths, pathsFilter || {}, funnelPathsFilter || undefined, colors), + [paths, pathsFilter, funnelPathsFilter, colors] + ) + const chartWidth = pathsChartWidth(containerWidth ?? FALLBACK_CANVAS_WIDTH, maxPathLayer(paths)) + + const highlight = useMemo(() => { + if (activeIndices.nodeIndices.size === 0) { + return null + } + const nodeIds = new Set() + for (const node of nodes) { + if (activeIndices.nodeIndices.has(node.index)) { + nodeIds.add(node.name) + } + } + return { nodeIds, linkIndices: activeIndices.linkIndices } + }, [activeIndices, nodes]) + + const onHoverChange = useCallback( + (hit: SankeyTooltipHit | null) => { + // A card under the cursor owns the hover until the pointer leaves it. + if (interactionLogic.values.cardHovered) { + return + } + if (!hit) { + requestClearHover() + } else if (hit.kind === 'node') { + hoverNode(hit.node.index) + } else { + hoverLink(hit.link.source.index, hit.link.target.index, hit.link.index) + } + }, + [interactionLogic, hoverNode, hoverLink, requestClearHover] + ) + + if (insightDataError) { + return ( + { + loadData(shouldQueryBeAsync(insightQuery) ? 'force_async' : 'force_blocking') + }} + /> + ) + } + + const id = `'${insight?.short_id || DEFAULT_PATHS_ID}'` + const isEmpty = !insightDataLoading && paths.nodes.length === 0 + + return ( +
+
0 ? 'true' : undefined} + // eslint-disable-next-line react/forbid-dom-props + style={ + { + width: chartWidth, + '--paths-link-hover': colors.selectedNode, + '--paths-dropoff': 'rgba(220,53,69,0.7)', + } as React.CSSProperties + } + > + {isEmpty ? : null} + {graph.nodes.length > 0 ? ( + + nodes={graph.nodes} + links={graph.links} + theme={chartTheme} + config={CHART_CONFIG} + highlight={highlight} + onHoverChange={onHoverChange} + className="h-full" + dataAttr="paths-sankey-chart" + > + + + + + ) : null} +
+
+ ) +} diff --git a/products/product_analytics/frontend/insights/paths/PathsColumnDividers.tsx b/products/product_analytics/frontend/insights/paths/PathsColumnDividers.tsx new file mode 100644 index 000000000000..c2ad209837c8 --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/PathsColumnDividers.tsx @@ -0,0 +1,22 @@ +import { useSankeyLayout } from '@posthog/quill-charts' + +/** Chart overlay: a divider before each step once the chart is wide enough to scroll, so the eye + * keeps its place in a long path. */ +export function PathsColumnDividers(): JSX.Element | null { + const { layout } = useSankeyLayout() + if (layout.columnCount <= 5) { + return null + } + return ( + <> + {layout.columnX.slice(1).map((x, i) => ( +
+ ))} + + ) +} diff --git a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx new file mode 100644 index 000000000000..70836af4df04 --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx @@ -0,0 +1,43 @@ +import { useSankeyLayout } from '@posthog/quill-charts' + +const DROPOFF_WIDTH = 30 +const DROPOFF_MAX_RADIUS = 25 + +/** Chart overlay: a fading block to the right of each node for the users who did not continue, + * sized to the part of the node no outgoing ribbon covers. First-step nodes and path ends get + * none, matching the SVG renderer. */ +export function PathsDropoffs(): JSX.Element { + const { layout } = useSankeyLayout() + const outflow = new Map() + for (const link of layout.links) { + outflow.set(link.source.id, (outflow.get(link.source.id) ?? 0) + link.width) + } + return ( + <> + {layout.nodes.map((node) => { + const continuing = outflow.get(node.id) + if (node.column === 0 || continuing === undefined) { + return null + } + const height = node.y1 - node.y0 - continuing + if (height < 1) { + return null + } + return ( +
+ ) + })} + + ) +} diff --git a/products/product_analytics/frontend/insights/paths/PathsLegacy.tsx b/products/product_analytics/frontend/insights/paths/PathsLegacy.tsx new file mode 100644 index 000000000000..496333cd118a --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/PathsLegacy.tsx @@ -0,0 +1,170 @@ +import './Paths.scss' + +import { useActions, useValues } from 'kea' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +import { useResizeObserver } from 'lib/hooks/useResizeObserver' +import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates' +import { insightDataLogic } from 'scenes/insights/insightDataLogic' +import { insightLogic } from 'scenes/insights/insightLogic' + +import { FunnelPathsFilter } from '~/queries/schema/schema-general' +import { shouldQueryBeAsync } from '~/queries/utils' + +import { FALLBACK_CANVAS_WIDTH } from './constants' +import { PathNodeCard } from './PathNodeCard' +import { pathsDataLogic } from './pathsDataLogic' +import { pathsInteractionLogic } from './pathsInteractionLogic' +import type { PathNodeData } from './pathUtils' +import type { PathsHoverHandlers } from './renderPaths' +import { renderPaths } from './renderPaths' + +const DEFAULT_PATHS_ID = 'default_paths' +const FALLBACK_CANVAS_HEIGHT = 0 +const CANVAS_RESIZE_DEBOUNCE_MS = 50 + +/** The d3 SVG renderer. `Paths` shows it while the `paths-sankey-chart` flag is off. */ +export function PathsLegacy(): JSX.Element { + const canvasRef = useRef(null) + const canvasContainerRef = useRef(null) + const { width: rawCanvasWidth, height: rawCanvasHeight } = useResizeObserver({ ref: canvasRef }) + const [canvasWidth, setCanvasWidth] = useState(FALLBACK_CANVAS_WIDTH) + const [canvasHeight, setCanvasHeight] = useState(FALLBACK_CANVAS_HEIGHT) + + // Debounce canvas dimension updates to prevent rapid SVG recreation from ResizeObserver. + // We do NOT remove data-stable here — the render effect below removes it only when the SVG + // is actually being recreated. Removing it here would cause data-stable to disappear for the + // entire debounce window on every resize, making Playwright's waitForSelector time out. + useEffect(() => { + const timer = setTimeout(() => { + setCanvasWidth(rawCanvasWidth ?? FALLBACK_CANVAS_WIDTH) + setCanvasHeight(rawCanvasHeight ?? FALLBACK_CANVAS_HEIGHT) + }, CANVAS_RESIZE_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [rawCanvasWidth, rawCanvasHeight]) + + const { insight, insightProps } = useValues(insightLogic) + const { insightQuery, paths, pathsFilter, funnelPathsFilter, insightDataLoading, insightDataError, theme } = + useValues(pathsDataLogic(insightProps)) + const { loadData } = useActions(insightDataLogic(insightProps)) + + const interactionLogic = pathsInteractionLogic(insightProps) + const { resolvedNodeCards, activeIndices, cardPopoverIndex } = useValues(interactionLogic) + const { setNodes, hoverNode, hoverLink, clearHover, requestClearHover, hoverCard, unhoverCard } = + useActions(interactionLogic) + + // The handlers must keep their identity for the lifetime of the component. They are a + // dependency of the render effect below, so a new object there tears the SVG out from under + // the pointer and clears the hover that had just been set. Anything that changes is read + // when the handler runs, not when it is built. + const canvasHeightRef = useRef(canvasHeight) + canvasHeightRef.current = canvasHeight + const hoverHandlers = useMemo( + () => ({ + onNodesReady: (nodes: PathNodeData[]) => setNodes(nodes, canvasHeightRef.current), + onNodeHover: hoverNode, + onLinkHover: hoverLink, + onHoverClear: requestClearHover, + isCardHovered: () => interactionLogic.values.cardHovered, + }), + [interactionLogic, setNodes, hoverNode, hoverLink, requestClearHover] + ) + + useLayoutEffect(() => { + canvasRef.current?.querySelectorAll('path[id^="path-"]').forEach((el) => { + const pathIndex = Number(el.id.replace('path-', '')) + el.setAttribute( + 'stroke', + activeIndices.linkIndices.has(pathIndex) ? 'var(--paths-link-hover)' : 'var(--paths-link)' + ) + }) + }, [activeIndices]) + + const id = `'${insight?.short_id || DEFAULT_PATHS_ID}'` + + useEffect(() => { + clearHover() + + const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`) + elements?.forEach((node) => node?.parentNode?.removeChild(node)) + + if (canvasRef.current) { + canvasRef.current.removeAttribute('data-stable') + } + + renderPaths( + canvasRef, + canvasWidth, + canvasHeight, + paths, + pathsFilter || {}, + funnelPathsFilter || ({} as FunnelPathsFilter), + hoverHandlers + ) + + if (canvasRef.current) { + canvasRef.current.setAttribute('data-stable', 'true') + } + + return () => { + const elements = canvasContainerRef.current?.querySelectorAll(`.Paths__canvas`) + elements?.forEach((node) => node?.parentNode?.removeChild(node)) + } + }, [ + paths, + insightDataLoading, + canvasWidth, + canvasHeight, + theme, + pathsFilter, + funnelPathsFilter, + hoverHandlers, + clearHover, + ]) + + if (insightDataError) { + return ( + { + loadData(shouldQueryBeAsync(insightQuery) ? 'force_async' : 'force_blocking') + }} + /> + ) + } + + return ( +
+
+ {!insightDataLoading && paths && paths.nodes.length === 0 && !insightDataError && } + {!insightDataError && + resolvedNodeCards.map((node, idx) => ( + hoverCard(node.index)} + onMouseLeave={unhoverCard} + /> + ))} +
+
+ ) +} diff --git a/products/product_analytics/frontend/insights/paths/constants.ts b/products/product_analytics/frontend/insights/paths/constants.ts index f3b8ce49361f..42d9fe403cf4 100644 --- a/products/product_analytics/frontend/insights/paths/constants.ts +++ b/products/product_analytics/frontend/insights/paths/constants.ts @@ -3,3 +3,6 @@ export const PATH_NODE_CARD_HEIGHT = 38 export const PATH_NODE_CARD_OVERLAP_GAP = 4 export const PATH_NODE_CARD_TOP_OFFSET = 5 export const PATH_NODE_CARD_LEFT_OFFSET = 7 +/** A node shorter than this shows its card only while it is part of the hovered path. */ +export const HIDE_PATH_CARD_HEIGHT = 30 +export const FALLBACK_CANVAS_WIDTH = 1000 diff --git a/products/product_analytics/frontend/insights/paths/pathUtils.ts b/products/product_analytics/frontend/insights/paths/pathUtils.ts index 45f58648df4b..d65fccc50f63 100644 --- a/products/product_analytics/frontend/insights/paths/pathUtils.ts +++ b/products/product_analytics/frontend/insights/paths/pathUtils.ts @@ -5,9 +5,12 @@ import { tryDecodeURIComponent } from 'lib/utils/url' import { FunnelPathsFilter, PathsFilter } from '~/queries/schema/schema-general' import { FunnelPathType } from '~/types' -import { PATH_NODE_CARD_HEIGHT, PATH_NODE_CARD_OVERLAP_GAP, PATH_NODE_CARD_TOP_OFFSET } from './constants' -// eslint-disable-next-line import/no-cycle -import { HIDE_PATH_CARD_HEIGHT } from './Paths' +import { + HIDE_PATH_CARD_HEIGHT, + PATH_NODE_CARD_HEIGHT, + PATH_NODE_CARD_OVERLAP_GAP, + PATH_NODE_CARD_TOP_OFFSET, +} from './constants' const PATH_NODE_CARD_TOP_ADJUSTMENTS = 33 @@ -19,7 +22,7 @@ export interface PathTargetLink { value: number width: number y0: number - color: RGBColor + color?: RGBColor } export interface PathNodeData { @@ -36,13 +39,17 @@ export interface PathNodeData { y0: number y1: number layer: number - source: PathNodeData - target: PathNodeData + /** Set by the SVG renderer, which types its link data as nodes. */ + source?: PathNodeData + target?: PathNodeData visible?: boolean active?: boolean resolvedTop?: number } +/** Path result keys carry the step as a `N_` prefix; the rest is the event or URL. */ +export const stripStepPrefix = (name: string): string => name.replace(/(^[0-9]+_)/, '') + export function getForwardConnectedIndices(startNode: PathNodeData): { nodeIndices: Set linkIndices: Set @@ -204,7 +211,7 @@ export function roundedRect( export function pageUrl(d: PathNodeData, display?: boolean, showFullUrls?: boolean): string { const incomingUrls = d.targetLinks - .map((l) => l?.source?.name?.replace(/(^[0-9]+_)/, '')) + .map((l) => stripStepPrefix(l?.source?.name ?? '')) .filter((a) => { try { new URL(a) @@ -216,7 +223,7 @@ export function pageUrl(d: PathNodeData, display?: boolean, showFullUrls?: boole .map((a) => new URL(a)) const incomingDomains = Array.from(new Set(incomingUrls.map((url) => url.origin))) - let name = d.name.replace(/(^[0-9]+_)/, '') + let name = stripStepPrefix(d.name) if (!display) { return name @@ -240,30 +247,46 @@ export function pageUrl(d: PathNodeData, display?: boolean, showFullUrls?: boole return name.length > 15 ? name.substring(0, 6) + '...' + name.slice(-8) : name.length < 4 && d.name.length < 25 - ? d.name.replace(/(^[0-9]+_)/, '') + ? stripStepPrefix(d.name) : name } -export const isSelectedPathStartOrEnd = ( +export interface PathEndpoint { + /** The node name without its step prefix. */ + name: string + isPathStart: boolean + isPathEnd: boolean +} + +/** Whether the node is the start or end point the filter selected, so it draws in the accent color. */ +export const isSelectedPathEndpoint = ( pathsFilter: PathsFilter, - funnelPathsFilter: FunnelPathsFilter, - pathItemCard: PathNodeData + funnelPathsFilter: FunnelPathsFilter | undefined, + { name, isPathStart, isPathEnd }: PathEndpoint ): boolean => { - const cardName = pageUrl(pathItemCard) - const isPathStart = pathItemCard.targetLinks.length === 0 - const isPathEnd = pathItemCard.sourceLinks.length === 0 const { startPoint, endPoint } = pathsFilter const { funnelPathType, funnelSource, funnelStep } = funnelPathsFilter || {} return ( - (startPoint === cardName && isPathStart) || - (endPoint === cardName && isPathEnd) || + (startPoint === name && isPathStart) || + (endPoint === name && isPathEnd) || (funnelPathType === FunnelPathType.between && - ((cardName === funnelSource?.series[funnelStep! - 1].name && isPathEnd) || - (cardName === funnelSource?.series[funnelStep! - 2].name && isPathStart))) + ((name === funnelSource?.series[funnelStep! - 1].name && isPathEnd) || + (name === funnelSource?.series[funnelStep! - 2].name && isPathStart))) ) } +export const isSelectedPathStartOrEnd = ( + pathsFilter: PathsFilter, + funnelPathsFilter: FunnelPathsFilter, + pathItemCard: PathNodeData +): boolean => + isSelectedPathEndpoint(pathsFilter, funnelPathsFilter, { + name: pageUrl(pathItemCard), + isPathStart: pathItemCard.targetLinks.length === 0, + isPathEnd: pathItemCard.sourceLinks.length === 0, + }) + export const calculatePathNodeCardTop = (node: PathNodeData, canvasHeight: number): number => { const isNodeCutoff = node.y1 - node.y0 < HIDE_PATH_CARD_HEIGHT && node.y1 > canvasHeight - HIDE_PATH_CARD_HEIGHT const isPathEnd = node.sourceLinks.length === 0 diff --git a/products/product_analytics/frontend/insights/paths/pathsChartData.test.ts b/products/product_analytics/frontend/insights/paths/pathsChartData.test.ts new file mode 100644 index 000000000000..d4d88a1b0ace --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/pathsChartData.test.ts @@ -0,0 +1,89 @@ +import type { SankeyChartLayout, SankeyLinkDatum, SankeyNodeDatum } from '@posthog/quill-charts' + +import { PathsLink } from '~/queries/schema/schema-general' + +import { buildPathsGraph, maxPathLayer, pathsChartWidth, toPathNodeData } from './pathsChartData' +import { Paths } from './types' + +const COLORS = { node: '#111111', selectedNode: '#222222', link: '#333333' } + +const LINKS: PathsLink[] = [ + { source: '1_/home', target: '2_/pricing', value: 30, average_conversion_time: 4000 }, + { source: '1_/home', target: '2_/docs', value: 10, average_conversion_time: 2000 }, + { source: '2_/pricing', target: '3_/signup', value: 12, average_conversion_time: 9000 }, +] +const PATHS: Paths = { + nodes: [{ name: '1_/home' }, { name: '2_/pricing' }, { name: '2_/docs' }, { name: '3_/signup' }], + links: LINKS, +} + +function layoutOf(paths: Paths): SankeyChartLayout { + const nodes = paths.nodes.map( + (node, index): SankeyNodeDatum => ({ + id: node.name, + label: node.name, + color: '#000', + index, + column: Number(node.name.match(/[^_]*/)) - 1, + value: 40, + x0: index * 100, + x1: index * 100 + 15, + y0: 0, + y1: 40, + }) + ) + const byId = new Map(nodes.map((node) => [node.id, node])) + const links = paths.links.map( + (link, index): SankeyLinkDatum => ({ + source: byId.get(link.source)!, + target: byId.get(link.target)!, + value: link.value, + color: '#000', + meta: link, + index, + y0: 0, + y1: 0, + width: link.value, + }) + ) + return { nodes, links, columnCount: 3, columnX: [0, 100, 300], total: 40, nodeWidth: 15 } +} + +describe('pathsChartData', () => { + it('colors the selected start point and keeps the result link on each ribbon', () => { + const graph = buildPathsGraph(PATHS, { startPoint: '/home' }, undefined, COLORS) + expect(graph.nodes.map((n) => n.color)).toEqual(['#222222', '#111111', '#111111', '#111111']) + // A selected start point that other steps lead into is not a path start, so no accent. + const notAStart = buildPathsGraph(PATHS, { startPoint: '/pricing' }, undefined, COLORS) + expect(notAStart.nodes[1].color).toBe('#111111') + expect(graph.links[2].meta).toBe(LINKS[2]) + }) + + it('rebuilds the node graph the hover logic walks, keyed by the chart indices', () => { + const nodes = toPathNodeData(layoutOf(PATHS)) + const home = nodes.find((n) => n.name === '1_/home')! + const signup = nodes.find((n) => n.name === '3_/signup')! + expect(nodes.map((n) => n.index)).toEqual([0, 1, 2, 3]) + expect(home.sourceLinks.map((l) => [l.index, l.target.name])).toEqual([ + [0, '2_/pricing'], + [1, '2_/docs'], + ]) + expect(signup.targetLinks[0]).toMatchObject({ index: 2, average_conversion_time: 9000, value: 12 }) + expect(signup.targetLinks[0].source).toBe(nodes[1]) + expect([home.layer, signup.layer]).toEqual([0, 2]) + }) + + it.each([ + [1200, 3, 1200], + [600, 2, 600], + [600, 3, 1000], + [1000, 8, 1600], + ])('sizes a %dpx container with %d steps to %dpx', (containerWidth, steps, expected) => { + expect(pathsChartWidth(containerWidth, steps)).toBe(expected) + }) + + it('reads the step count from the link targets', () => { + expect(maxPathLayer(PATHS)).toBe(3) + expect(maxPathLayer({ nodes: [], links: [] })).toBe(0) + }) +}) diff --git a/products/product_analytics/frontend/insights/paths/pathsChartData.ts b/products/product_analytics/frontend/insights/paths/pathsChartData.ts new file mode 100644 index 000000000000..ebc9975307be --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/pathsChartData.ts @@ -0,0 +1,103 @@ +import type { SankeyChartLayout, SankeyLinkInput, SankeyNodeInput } from '@posthog/quill-charts' + +import { FunnelPathsFilter, PathsFilter, PathsLink } from '~/queries/schema/schema-general' + +import { FALLBACK_CANVAS_WIDTH } from './constants' +import { PathNodeData, PathTargetLink, isSelectedPathEndpoint, stripStepPrefix } from './pathUtils' +import { Paths } from './types' + +export interface PathsGraphColors { + node: string + /** The start or end point the filter selected. */ + selectedNode: string + link: string +} + +export interface PathsGraph { + nodes: SankeyNodeInput[] + links: SankeyLinkInput[] +} + +/** Chart inputs for a paths result. Node ids are the result's `N_name` keys, which are unique per + * step, so the same page at two steps is two nodes. */ +export function buildPathsGraph( + paths: Paths, + pathsFilter: PathsFilter, + funnelPathsFilter: FunnelPathsFilter | undefined, + colors: PathsGraphColors +): PathsGraph { + const hasIncoming = new Set() + const hasOutgoing = new Set() + for (const link of paths.links) { + hasOutgoing.add(link.source) + hasIncoming.add(link.target) + } + const nodes = paths.nodes.map((node): SankeyNodeInput => { + const selected = isSelectedPathEndpoint(pathsFilter, funnelPathsFilter, { + name: stripStepPrefix(node.name), + isPathStart: !hasIncoming.has(node.name), + isPathEnd: !hasOutgoing.has(node.name), + }) + return { id: node.name, color: selected ? colors.selectedNode : colors.node } + }) + const links = paths.links.map( + (link): SankeyLinkInput => ({ + source: link.source, + target: link.target, + value: link.value, + color: colors.link, + meta: link, + }) + ) + return { nodes, links } +} + +/** The laid-out graph in the shape the card and hover logic works on. Node and link indices are + * the chart's, so a hover reported by the chart addresses the same node the logic holds. */ +export function toPathNodeData(layout: SankeyChartLayout): PathNodeData[] { + const nodes = layout.nodes.map( + (node): PathNodeData => ({ + name: node.id, + targetLinks: [], + sourceLinks: [], + depth: node.column, + layer: node.column, + index: node.index, + value: node.value, + x0: node.x0, + x1: node.x1, + y0: node.y0, + y1: node.y1, + width: node.x1 - node.x0, + height: node.y1 - node.y0, + }) + ) + for (const link of layout.links) { + const source = nodes[link.source.index] + const target = nodes[link.target.index] + const targetLink: PathTargetLink = { + average_conversion_time: link.meta?.average_conversion_time ?? 0, + index: link.index, + source, + target, + value: link.value, + width: link.width, + y0: link.y0, + } + source.sourceLinks.push(targetLink) + target.targetLinks.push(targetLink) + } + return nodes +} + +export function maxPathLayer(paths: Paths): number { + return paths.links.reduce((max, link) => Math.max(max, Number(link.target.match(/[^_]*/))), 0) +} + +/** Chart width for a container. Past five steps the chart grows by a fifth of the container per + * step and the container scrolls, so long paths keep readable column spacing. A narrow container + * with three or more steps gets the fallback width for the same reason. */ +export function pathsChartWidth(containerWidth: number, maxLayer: number): number { + const minWidth = containerWidth > FALLBACK_CANVAS_WIDTH || maxLayer < 3 ? containerWidth : FALLBACK_CANVAS_WIDTH + return maxLayer > 5 && containerWidth ? (minWidth / 5) * maxLayer : minWidth +} diff --git a/products/product_analytics/frontend/insights/paths/renderPaths.ts b/products/product_analytics/frontend/insights/paths/renderPaths.ts index 073c26f9d22c..d8d9987870f7 100644 --- a/products/product_analytics/frontend/insights/paths/renderPaths.ts +++ b/products/product_analytics/frontend/insights/paths/renderPaths.ts @@ -14,9 +14,8 @@ import { stripHTTP } from 'lib/utils/url' import { FunnelPathsFilter, PathsFilter } from '~/queries/schema/schema-general' -// eslint-disable-next-line import/no-cycle -import { FALLBACK_CANVAS_WIDTH } from './Paths' -import { PathNodeData, isSelectedPathStartOrEnd, roundedRect } from './pathUtils' +import { FALLBACK_CANVAS_WIDTH } from './constants' +import { PathNodeData, PathTargetLink, isSelectedPathStartOrEnd, roundedRect } from './pathUtils' import { Paths } from './types' export interface PathsHoverHandlers { @@ -125,11 +124,11 @@ const appendPathLinks = (svg: any, links: SankeyLink[], handle link.append('path') .attr('d', sankeyLinkHorizontal()) - .attr('id', (d: PathNodeData) => `path-${d.index}`) - .attr('stroke-width', (d: PathNodeData) => { + .attr('id', (d: PathTargetLink) => `path-${d.index}`) + .attr('stroke-width', (d: PathTargetLink) => { return Math.max(1, d.width) }) - .on('mouseover', (_event: MouseEvent, data: PathNodeData) => { + .on('mouseover', (_event: MouseEvent, data: PathTargetLink) => { if (handlers.isCardHovered()) { return } @@ -144,7 +143,7 @@ const appendPathLinks = (svg: any, links: SankeyLink[], handle link.append('g') .append('path') - .attr('d', (data: PathNodeData) => { + .attr('d', (data: PathTargetLink) => { if (data.source.layer === 0) { return } @@ -154,7 +153,7 @@ const appendPathLinks = (svg: any, links: SankeyLink[], handle }) .attr('fill', 'url(#dropoff-gradient)') .attr('stroke-width', 0) - .attr('transform', (data: PathNodeData) => { + .attr('transform', (data: PathTargetLink) => { return ( 'translate(' + Math.round(data.source.x1) + From 591b64b4a1700e39e6535939ac37f1131acb8fe3 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 08:29:53 +0100 Subject: [PATCH 34/80] fix(quill-charts): reset sankey hover dedup key on layout change computeSankeyLayout builds fresh node/link objects on every call, so a stale reportedHitRef key could coincidentally match a hit index after a data reload and suppress onHoverChange until the pointer left and returned. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../charts/src/charts/SankeyChart/useSankeyInteraction.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 2ffe3de1531b..526611fe37bb 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -95,6 +95,12 @@ export function useSankeyInteraction({ // The last hit reported to the host, so a cursor sweep inside one ribbon reports it once. const reportedHitRef = useRef(null) + // A new layout rebuilds nodes/links with fresh objects, so a stale key can coincidentally + // match the next real hit and suppress the hover callback the consumer needs to update. + useEffect(() => { + reportedHitRef.current = null + }, [layout]) + const reportHover = useCallback( (hit: SankeyHit | null) => { const key = hitKey(hit) From d18ea89ea5877dc40d51d1aa251036158d6cd0ca Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 08:35:05 +0100 Subject: [PATCH 35/80] refactor(paths): memoize chart width and dropoff totals Both derive from the layout and the query result, so they only need to recompute when those change rather than on every render. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../frontend/insights/paths/PathsChart.tsx | 5 ++++- .../frontend/insights/paths/PathsDropoffs.tsx | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/products/product_analytics/frontend/insights/paths/PathsChart.tsx b/products/product_analytics/frontend/insights/paths/PathsChart.tsx index b0f1845dbc65..89869542d213 100644 --- a/products/product_analytics/frontend/insights/paths/PathsChart.tsx +++ b/products/product_analytics/frontend/insights/paths/PathsChart.tsx @@ -68,7 +68,10 @@ export function PathsChart(): JSX.Element { () => buildPathsGraph(paths, pathsFilter || {}, funnelPathsFilter || undefined, colors), [paths, pathsFilter, funnelPathsFilter, colors] ) - const chartWidth = pathsChartWidth(containerWidth ?? FALLBACK_CANVAS_WIDTH, maxPathLayer(paths)) + const chartWidth = useMemo( + () => pathsChartWidth(containerWidth ?? FALLBACK_CANVAS_WIDTH, maxPathLayer(paths)), + [containerWidth, paths] + ) const highlight = useMemo(() => { if (activeIndices.nodeIndices.size === 0) { diff --git a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx index 70836af4df04..88823745654f 100644 --- a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx +++ b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { useSankeyLayout } from '@posthog/quill-charts' const DROPOFF_WIDTH = 30 @@ -8,10 +10,13 @@ const DROPOFF_MAX_RADIUS = 25 * none, matching the SVG renderer. */ export function PathsDropoffs(): JSX.Element { const { layout } = useSankeyLayout() - const outflow = new Map() - for (const link of layout.links) { - outflow.set(link.source.id, (outflow.get(link.source.id) ?? 0) + link.width) - } + const outflow = useMemo(() => { + const totals = new Map() + for (const link of layout.links) { + totals.set(link.source.id, (totals.get(link.source.id) ?? 0) + link.width) + } + return totals + }, [layout.links]) return ( <> {layout.nodes.map((node) => { From d0441ea94d015a4a1e635906ffcc1cc417e61816 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:31:16 +0100 Subject: [PATCH 36/80] fix(quill-charts): keep sankey hover reports and overlay in step with the host - report a null hover when a layout change drops the hovered item, when the layout is empty, and on a touch tap in empty space - repaint the hover overlay when a controlled highlight starts or ends Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 25 ++++++++++++++----- .../src/charts/SankeyChart/SankeyChart.tsx | 24 ++++++++++-------- .../SankeyChart/useSankeyInteraction.ts | 13 +++++++--- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 51351408d4a8..d62eae0d78ad 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -94,28 +94,41 @@ describe('SankeyChart', () => { /> ) 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( - , + , { 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('fires the click on the first tap when the tooltip is disabled', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index e3dc5d3530d7..9d9df99349d0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -135,10 +135,21 @@ function SankeyChartInner({ [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, 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( - () => (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({ @@ -151,13 +162,6 @@ function SankeyChartInner({ onHoverChange, }) - // 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, highlight) : null), - [layout, highlight] - ) const drawStatic = useCallback( ({ ctx: drawCtx, theme: drawTheme }: ChartDrawArgs) => drawSankey(drawCtx, layout as SankeyChartLayout, { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 526611fe37bb..0c344edc9d75 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -96,10 +96,14 @@ export function useSankeyInteraction({ const reportedHitRef = useRef(null) // A new layout rebuilds nodes/links with fresh objects, so a stale key can coincidentally - // match the next real hit and suppress the hover callback the consumer needs to update. + // match the next real hit and suppress the hover callback the consumer needs to update. The + // reported hit is gone with the old layout, so the host hears that too. useEffect(() => { - reportedHitRef.current = null - }, [layout]) + if (reportedHitRef.current !== null) { + reportedHitRef.current = null + onHoverChangeRef.current?.(null) + } + }, [layout, onHoverChangeRef]) const reportHover = useCallback( (hit: SankeyHit | null) => { @@ -165,6 +169,7 @@ export function useSankeyInteraction({ (e: React.MouseEvent) => { const current = layoutRef.current if (current.nodes.length === 0) { + reportHover(null) return } if (originatesInInteractiveOverlay(e)) { @@ -207,6 +212,7 @@ export function useSankeyInteraction({ hit = sankeyHitAt(current, cursor) if (!hit) { clearTooltip() + reportHover(null) return } if (hitToHoverIndex(current, hit) !== tapDownTooltipIndexRef.current) { @@ -232,6 +238,7 @@ export function useSankeyInteraction({ lastPointerTypeRef, tapDownTooltipIndexRef, clearTooltip, + reportHover, showHit, showTooltip, onNodeClick, From 59fd97ccb67db1539fc2b4dc303fc315758def73 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:44:18 +0100 Subject: [PATCH 37/80] fix(quill-charts): keep emphasized sankey links at least as opaque as at rest Also document that onHoverChange reports touch taps and layout changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../quill/packages/charts/src/charts/SankeyChart/types.ts | 5 +++-- packages/quill/packages/charts/src/docs/chart-types.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index 1da5c9b27243..6a1497df1ef0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -61,8 +61,9 @@ export interface SankeyChartProps { tooltip?: (ctx: SankeyTooltipContext) => React.ReactNode onNodeClick?: (node: SankeyNodeDatum) => void onLinkClick?: (link: SankeyLinkDatum) => void - /** Fires when the cursor moves onto a different node or ribbon, and with `null` when it leaves - * the graph. Lets a host drive its own emphasis, for example through `highlight`. */ + /** Fires when the cursor moves onto a different node or ribbon or a touch tap lands on one, and + * with `null` when the cursor leaves the graph, a tap lands on empty space, or the layout + * changes. Lets a host drive its own emphasis, for example through `highlight`. */ onHoverChange?: (hit: SankeyTooltipHit | null) => void /** Controlled emphasis. While set, the chart dims everything outside this set and the built-in * hover dimming is off, so the host decides what a hover means (the whole downstream path, a diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 84fbd2369061..06a98019f845 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -94,7 +94,7 @@ There is no `series` or `labels`. `onNodeClick` and `onLinkClick` receive the laid-out datum with its `meta`. `tooltip.placement` takes the cartesian charts' values and defaults to `cursor`. On touch, the first tap on a node or ribbon shows its tooltip and a second tap on the same one fires the click handler. -- `onHoverChange` reports the node or ribbon under the cursor, and `null` when it leaves. +- `onHoverChange` reports the node or ribbon under the cursor or a touch tap, and `null` when the cursor leaves, a tap lands on empty space, or the layout changes. `highlight` takes over emphasis: the chart dims everything outside `{ nodeIds, linkIndices }` and stops its own hover dimming, so a host can light up a whole downstream path or a selection. Link indices are positions in the `links` prop; the layout keeps that order. - Custom overlays read `useSankeyLayout()` for the positioned `nodes`, `links`, `columnX`, and `total`. From 6290e29be7c4379b6cb3e580bed4efaa90eec28a Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:56:54 +0100 Subject: [PATCH 38/80] fix(paths): lazy-load the sankey paths chart and address review - load PathsChart on demand so the legacy renderer does not pull in SankeyChart - paint emphasized ribbons under every node, as on the static layer - ignore clicks and taps that start on an interactive overlay Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 23 +++++++++++++++++++ .../SankeyChart/useSankeyInteraction.ts | 5 ++++ .../frontend/insights/paths/Paths.tsx | 22 ++++++++++++++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index d62eae0d78ad..fd26381a08d9 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -131,6 +131,29 @@ describe('SankeyChart', () => { 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( + + + + ) + 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', () => { const onNodeClick = jest.fn() const { chart } = renderHogChart( diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 0c344edc9d75..fc9d321e66f7 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -201,7 +201,12 @@ export function useSankeyInteraction({ const onClick = useCallback( (e: React.MouseEvent) => { + // A click or tap meant for an overlay control must not act on the node behind it. if (originatesInInteractiveOverlay(e)) { + if (lastPointerTypeRef.current === 'touch') { + clearTooltip() + reportHover(null) + } return } const current = layoutRef.current diff --git a/products/product_analytics/frontend/insights/paths/Paths.tsx b/products/product_analytics/frontend/insights/paths/Paths.tsx index bc5dadced506..80920f76d17d 100644 --- a/products/product_analytics/frontend/insights/paths/Paths.tsx +++ b/products/product_analytics/frontend/insights/paths/Paths.tsx @@ -1,12 +1,30 @@ import { useValues } from 'kea' +import { Suspense, lazy } from 'react' import { FEATURE_FLAGS } from 'lib/constants' +import { Spinner } from 'lib/lemon-ui/Spinner' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { retryImport } from 'lib/utils/retryImport' -import { PathsChart } from './PathsChart' import { PathsLegacy } from './PathsLegacy' +// Loaded on demand so users on the legacy renderer do not download the Sankey chart. +const PathsChart = lazy(() => retryImport(() => import('./PathsChart').then((m) => ({ default: m.PathsChart })))) + export function Paths(): JSX.Element { const { featureFlags } = useValues(featureFlagLogic) - return featureFlags[FEATURE_FLAGS.PRODUCT_ANALYTICS_PATHS_SANKEY_CHART] ? : + if (!featureFlags[FEATURE_FLAGS.PRODUCT_ANALYTICS_PATHS_SANKEY_CHART]) { + return + } + return ( + + +
+ } + > + + + ) } From 2e62824f972585eee2c07afc9b6efb87cb04ae46 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 16:35:27 +0100 Subject: [PATCH 39/80] fix(quill-charts): call emphasisForHit in the sankey draw test Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/draw-sankey.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index d0841cf27f3b..7f9e1245143b 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -1,4 +1,4 @@ -import { drawSankeyHover, sankeyActiveFlow } from './draw-sankey' +import { drawSankeyHover, emphasisForHit } from './draw-sankey' import { computeSankeyLayout } from './sankey-data' const BACKGROUND = 'hsl(235deg 8% 15%)' @@ -66,7 +66,7 @@ describe('drawSankeyHover', () => { (_name, serialize, secondLinkValue, expectedStrokes) => { const layout = layoutOf(secondLinkValue) const { ctx, strokes } = recordingCtx(serialize) - drawSankeyHover(ctx, layout, sankeyActiveFlow(layout, { kind: 'link', index: 0 }), { + drawSankeyHover(ctx, layout, emphasisForHit(layout, { kind: 'link', index: 0 }), { linkOpacity: 0.4, backgroundColor: BACKGROUND, progress: 1, From ba31e1f96972aef8bdc8068683d020427805fd67 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 16:45:24 +0100 Subject: [PATCH 40/80] fix(paths): mark the node card popover as a chart overlay Co-Authored-By: Claude Opus 5.5 (1M context) --- .../frontend/insights/paths/PathNodeCardMenu.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/products/product_analytics/frontend/insights/paths/PathNodeCardMenu.tsx b/products/product_analytics/frontend/insights/paths/PathNodeCardMenu.tsx index 9fee51ecdfe9..7a934322870c 100644 --- a/products/product_analytics/frontend/insights/paths/PathNodeCardMenu.tsx +++ b/products/product_analytics/frontend/insights/paths/PathNodeCardMenu.tsx @@ -38,6 +38,9 @@ export function PathNodeCardMenu({ return (
From 698a1cfcc541c6625ba3084c2f587d4d2c17154e Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 17:01:35 +0100 Subject: [PATCH 41/80] perf(paths): only check nearby cards when placing a hover-only card Co-Authored-By: Claude Opus 5.5 (1M context) --- .../frontend/insights/paths/pathUtils.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/products/product_analytics/frontend/insights/paths/pathUtils.ts b/products/product_analytics/frontend/insights/paths/pathUtils.ts index d65fccc50f63..80541c1e027f 100644 --- a/products/product_analytics/frontend/insights/paths/pathUtils.ts +++ b/products/product_analytics/frontend/insights/paths/pathUtils.ts @@ -104,13 +104,19 @@ const findClosestAvailableCardTop = ( const minimumDistance = PATH_NODE_CARD_HEIGHT + PATH_NODE_CARD_OVERLAP_GAP const maximumTop = Math.max(0, canvasHeight - PATH_NODE_CARD_HEIGHT) const clamp = (top: number): number => Math.min(Math.max(top, 0), maximumTop) + // A candidate sits within MAXIMUM_CARD_NUDGE of naturalTop, and only a card within + // minimumDistance of a candidate can block it, so cards further away cannot change the result. + // Skipping them stops a dense layer from checking every card against every candidate. + const nearbyTops = occupiedTops.filter( + (occupiedTop) => Math.abs(occupiedTop - naturalTop) <= MAXIMUM_CARD_NUDGE + minimumDistance + ) const candidateTops = [ clamp(naturalTop), - ...occupiedTops.flatMap((occupiedTop) => [occupiedTop - minimumDistance, occupiedTop + minimumDistance]), + ...nearbyTops.flatMap((occupiedTop) => [occupiedTop - minimumDistance, occupiedTop + minimumDistance]), ].filter((top) => top >= 0 && top <= maximumTop && Math.abs(top - naturalTop) <= MAXIMUM_CARD_NUDGE) const freeTop = candidateTops - .filter((top) => occupiedTops.every((occupiedTop) => Math.abs(top - occupiedTop) >= minimumDistance)) + .filter((top) => nearbyTops.every((occupiedTop) => Math.abs(top - occupiedTop) >= minimumDistance)) .sort((a, b) => Math.abs(a - naturalTop) - Math.abs(b - naturalTop) || a - b)[0] return freeTop === undefined ? { top: clamp(naturalTop), foundGap: false } : { top: freeTop, foundGap: true } From a24bb445b8160345a98171e6b02dd62ca0f38b95 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 00:24:29 +0100 Subject: [PATCH 42/80] fix(quill-charts): restore the useRef import and emphasisForHit in sankey tests Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/charts/src/charts/SankeyChart/draw-sankey.test.ts | 2 +- .../charts/src/charts/SankeyChart/useSankeyInteraction.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index 7f9e1245143b..3709a49003f3 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -85,7 +85,7 @@ describe('drawSankeyHover', () => { layout.links[1].color = 'transparent' layout.nodes.find((node) => node.id === 'c')!.color = 'transparent' const { ctx, strokes, fills } = recordingCtx({ [BACKGROUND]: '#202023' }) - drawSankeyHover(ctx, layout, sankeyActiveFlow(layout, { kind: 'link', index: 0 }), { + drawSankeyHover(ctx, layout, emphasisForHit(layout, { kind: 'link', index: 0 }), { linkOpacity: 0.4, backgroundColor: BACKGROUND, progress: 1, diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index fc9d321e66f7..d2e43d1971cf 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { originatesInInteractiveOverlay } from '../../core/dom-events' import { useLatest } from '../../core/hooks/useLatest' From 39f6d327d25df212e80a281d96dd48813df666c7 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 14:04:28 +0100 Subject: [PATCH 43/80] fix(quill-charts): stop emphasized sankey ribbons compounding past their target opacity Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/draw-sankey.test.ts | 39 ++++++++++++++++--- .../src/charts/SankeyChart/draw-sankey.ts | 10 ++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index 3709a49003f3..e89c2c3bca7a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -1,4 +1,4 @@ -import { drawSankeyHover, emphasisForHit } from './draw-sankey' +import { drawSankey, drawSankeyHover, emphasisForHit } from './draw-sankey' import { computeSankeyLayout } from './sankey-data' const BACKGROUND = 'hsl(235deg 8% 15%)' @@ -24,10 +24,13 @@ function layoutOf(secondLinkValue = 10): ReturnType function recordingCtx(serialize: Record): { ctx: CanvasRenderingContext2D strokes: string[] + strokeAlphas: number[] fills: string[] } { const strokes: string[] = [] + const strokeAlphas: number[] = [] const fills: string[] = [] + const savedAlphas: number[] = [] let fill = '#000000' let stroke = '#000000' const ctx = { @@ -48,12 +51,17 @@ function recordingCtx(serialize: Record): { beginPath: () => {}, moveTo: () => {}, bezierCurveTo: () => {}, - save: () => {}, - restore: () => {}, + save: () => savedAlphas.push(ctx.globalAlpha), + restore: () => { + ctx.globalAlpha = savedAlphas.pop() ?? 1 + }, fillRect: () => fills.push(fill), - stroke: () => strokes.push(stroke), + stroke: () => { + strokes.push(stroke) + strokeAlphas.push(ctx.globalAlpha) + }, } as unknown as CanvasRenderingContext2D - return { ctx, strokes, fills } + return { ctx, strokes, strokeAlphas, fills } } describe('drawSankeyHover', () => { @@ -94,4 +102,25 @@ describe('drawSankeyHover', () => { expect(strokes).toHaveLength(1) expect(fills).toHaveLength(2) }) + + it.each([ + ['static layer below the hover target', 'static', 0.4, 0.85], + ['hover layer below the hover target', 'hover', 0.4, 0.85], + ['static layer above the hover target', 'static', 0.9, 0.9], + ['hover layer above the hover target', 'hover', 0.9, 0.9], + ])('composites an emphasized ribbon to its target opacity: %s', (_name, layer, linkOpacity, expectedOpacity) => { + const layout = layoutOf(0) + const emphasis = emphasisForHit(layout, { kind: 'link', index: 0 }) + const { ctx, strokeAlphas } = recordingCtx({ [BACKGROUND]: '#202023' }) + if (layer === 'static') { + drawSankey(ctx, layout, { linkOpacity, emphasis, backgroundColor: BACKGROUND }) + } else { + drawSankeyHover(ctx, layout, emphasis, { linkOpacity, backgroundColor: BACKGROUND, progress: 1 }) + } + + // The hover layer sits on a static layer that already drew the ribbon at rest. + const painted = layer === 'hover' ? [linkOpacity, ...strokeAlphas] : strokeAlphas + const composite = 1 - painted.reduce((clear, alpha) => clear * (1 - alpha), 1) + expect(composite).toBeCloseTo(expectedOpacity) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index e15975c886b6..27bf3c7715b0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -150,8 +150,14 @@ function paintEmphasis( // A resting opacity above the hover target must not drop when the flow is emphasized. const targetOpacity = Math.max(HOVER_LINK_OPACITY, options.linkOpacity) const activeOpacity = options.linkOpacity + (targetOpacity - options.linkOpacity) * options.progress - for (const link of emphasis.links) { - strokeLink(ctx, link, link.color, activeOpacity) + // The emphasized ribbon is already on the canvas at `linkOpacity`, and canvas alpha compounds. + // This repaint opacity makes the composite equal `activeOpacity` instead of overshooting it. + const repaintOpacity = + options.linkOpacity < 1 ? (activeOpacity - options.linkOpacity) / (1 - options.linkOpacity) : 0 + if (repaintOpacity > 0) { + for (const link of emphasis.links) { + strokeLink(ctx, link, link.color, repaintOpacity) + } } // Every ribbon goes under every node, as on the resting layer, so an emphasized ribbon that // crosses a column does not cover a node it does not touch. From 0ea40e874ba1eb46017817b31f4ef6bdd7bdc0b0 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 14:04:30 +0100 Subject: [PATCH 44/80] fix(paths): stop drop-off blocks hovering the ribbons beneath them Co-Authored-By: Claude Opus 5.5 (1M context) --- .../frontend/insights/paths/PathsDropoffs.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx index 88823745654f..b849470cb845 100644 --- a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx +++ b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx @@ -31,6 +31,9 @@ export function PathsDropoffs(): JSX.Element { return (
Date: Wed, 7 Oct 2026 14:05:57 +0100 Subject: [PATCH 45/80] refactor(quill-charts): drop the dead overlay check in sankey hit-testing Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/useSankeyInteraction.ts | 2 +- .../product_analytics/frontend/insights/paths/PathsDropoffs.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index d2e43d1971cf..0a19cf6ed785 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -179,7 +179,7 @@ export function useSankeyInteraction({ } const rect = e.currentTarget.getBoundingClientRect() const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } - const hit = originatesInInteractiveOverlay(e) ? null : sankeyHitAt(current, cursor) + const hit = sankeyHitAt(current, cursor) if (!hit) { clearTooltip() reportHover(null) diff --git a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx index b849470cb845..924ed690c3d0 100644 --- a/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx +++ b/products/product_analytics/frontend/insights/paths/PathsDropoffs.tsx @@ -32,7 +32,7 @@ export function PathsDropoffs(): JSX.Element {
Date: Wed, 7 Oct 2026 20:47:41 +0000 Subject: [PATCH 46/80] chore(visual): update storybook baselines 12 updated Run: c3d59cd1-81e7-4a6f-a813-2b5f548aff38 Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- frontend/snapshots.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index a937ae4607db..0eac8b3525ac 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -204,6 +204,10 @@ 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: @@ -3832,6 +3836,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: From ddd970800c35e81e8a23601c5ff8a98ea5e902db Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 22 Sep 2026 23:48:17 +0100 Subject: [PATCH 47/80] feat(mcp): render paths query results as a Sankey in the MCP UI app Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../charts/SankeyChart/SankeyChart.test.tsx | 23 +++ .../charts/SankeyChart/SankeyNodeLabels.tsx | 33 +++- .../charts/SankeyChart/sankey-data.test.ts | 11 ++ .../src/charts/SankeyChart/sankey-data.ts | 15 +- .../src/charts/SankeyChart/sankey-layout.ts | 24 ++- .../packages/charts/src/docs/chart-types.md | 2 + .../src/ui-apps/components/Paths.stories.tsx | 110 ++++++++++++ .../ui-apps/components/PathsVisualizer.tsx | 166 ++++++++++++------ 8 files changed, 326 insertions(+), 58 deletions(-) create mode 100644 services/mcp/src/ui-apps/components/Paths.stories.tsx diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index fd26381a08d9..49c660676893 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -50,6 +50,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( + + ) + 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() diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx index c6a59502d4ba..3989b422204a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -2,10 +2,13 @@ import React from 'react' import { FONT_FAMILY, truncateToWidth } from '../../utils/text-measure' import { useSankeyLayout } from './sankey-context' +import type { SankeyNodeDatum } from './sankey-data' const LABEL_FONT_SIZE = 11 const LABEL_FONT = `${LABEL_FONT_SIZE}px ${FONT_FAMILY}` const LABEL_GAP = 6 +/** Vertical room one label needs; two label centers closer than this overlap. */ +const LABEL_LINE_HEIGHT = LABEL_FONT_SIZE * 1.2 const LABEL_STYLE_BASE: React.CSSProperties = { position: 'absolute', @@ -22,6 +25,30 @@ export interface SankeyNodeLabelsProps { valueFormatter: (value: number) => string } +const centerY = (node: SankeyNodeDatum): number => (node.y0 + node.y1) / 2 + +/** Nodes whose label fits without covering a neighbor's. Thin nodes stacked in one column would + * print on top of each other, so the larger flow keeps its label and the tooltip still names the + * rest. */ +export function labeledNodes(nodes: SankeyNodeDatum[]): Set { + const kept = new Set() + const byColumn = new Map() + for (const node of nodes) { + byColumn.set(node.column, [...(byColumn.get(node.column) ?? []), node]) + } + for (const column of byColumn.values()) { + const placed: number[] = [] + for (const node of [...column].sort((a, b) => b.value - a.value)) { + const y = centerY(node) + if (placed.every((other) => Math.abs(other - y) >= LABEL_LINE_HEIGHT)) { + placed.push(y) + kept.add(node) + } + } + } + return kept +} + /** One label per node, to the right of it; the last column's labels sit to its left so they stay * inside the plot. Labels truncate to the free space before the next column. */ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNodeLabelsProps): React.ReactElement { @@ -29,17 +56,21 @@ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNo const occupiedColumns = layout.columnX.filter((x): x is number => Number.isFinite(x)) const gap = occupiedColumns.length > 1 ? occupiedColumns[1] - occupiedColumns[0] - layout.nodeWidth : Infinity const maxWidth = Math.max(0, gap - LABEL_GAP * 2) + const shownNodes = labeledNodes(layout.nodes) return ( <> {layout.nodes.map((node) => { + if (!shownNodes.has(node)) { + return null + } const text = showValues ? `${node.label} ${valueFormatter(node.value)}` : node.label const last = node.column === layout.columnCount - 1 && layout.columnCount > 1 const shown = truncateToWidth(text, maxWidth, LABEL_FONT) const style: React.CSSProperties = { ...LABEL_STYLE_BASE, color, - top: (node.y0 + node.y1) / 2, + top: centerY(node), ...(last ? { right: `calc(100% - ${node.x0 - LABEL_GAP}px)` } : { left: node.x1 + LABEL_GAP }), } return ( diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 9ebcdc58ff17..6ff169b75d38 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -61,6 +61,17 @@ describe('computeSankeyLayout', () => { expect([columnOf('right', 'start'), columnOf('right', 'a'), columnOf('right', 'ended')]).toEqual([0, 1, 2]) }) + it('pins a node to its own column and widens the graph to fit it', () => { + // A late stage with no link from the earlier stages: depth alone would put `late` in the + // first column, and the graph would have three columns instead of five. + const nodes: SankeyNodeInput[] = [...NODES, { id: 'late', column: 3 }, { id: 'last', column: 4 }] + const links: SankeyLinkInput[] = [...LINKS, { source: 'late', target: 'last', value: 2 }] + const layout = layoutOf({ nodes, links, nodeAlign: 'left' }) + const columnOf = (id: string): number | undefined => layout.nodes.find((n) => n.id === id)?.column + expect([columnOf('start'), columnOf('done'), columnOf('late'), columnOf('last')]).toEqual([0, 2, 3, 4]) + expect(layout.columnCount).toBe(5) + }) + it('resolves node colors by label and defaults link color to the source node', () => { const layout = layoutOf({ nodes: [...NODES.slice(0, 3), { id: 'done', label: 'Completed', color: 'var(--success)' }], diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index da5893194b0c..e369312ea0e9 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -13,6 +13,10 @@ export interface SankeyNodeInput { color?: string /** Consumer data handed back through tooltips and click handlers. */ meta?: Meta + /** Pin the node to this zero-based column. Use it when the data carries its own stage (the + * step in a paths result), so a flow that ends early or starts late still sits under the right + * `columnLabels` header. Nodes without a pin follow `nodeAlign`. */ + column?: number } /** A directed flow from `source` to `target` (both node ids). The graph must be acyclic. */ @@ -97,6 +101,7 @@ interface LayoutNodeProps { label: string color: string meta?: unknown + pinnedColumn?: number [key: string]: unknown } @@ -209,8 +214,15 @@ export function computeSankeyLayout({ // The engine mutates its inputs, so hand it fresh objects. const engineNodes: LayoutNodeProps[] = nodes.map((node) => { const label = node.label ?? node.id - return { id: node.id, label, color: resolveColor(node.color || colorForLabel(label)), meta: node.meta } + return { + id: node.id, + label, + color: resolveColor(node.color || colorForLabel(label)), + meta: node.meta, + pinnedColumn: node.column, + } }) + const hasPinnedColumns = nodes.some((node) => node.column !== undefined) const engineLinks: LayoutLinkProps[] = links.map((link) => ({ source: link.source, target: link.target, @@ -222,6 +234,7 @@ export function computeSankeyLayout({ const graph = sankeyLayout() .nodeId((node) => node.id) .nodeAlign(ALIGNMENTS[nodeAlign]) + .nodeColumn(hasPinnedColumns ? (node) => node.pinnedColumn : null) .nodeSort(preserveNodeOrder ? null : undefined) .nodeWidth(effectiveNodeWidth) .nodePadding(nodePadding) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index a1ab2335eee0..1267571f235e 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -70,6 +70,11 @@ export interface SankeyLayout, n: number) => number nodeAlign(nodeAlign: (node: SankeyNode, n: number) => number): this + /** Pins a node to a column regardless of its depth in the graph; return `undefined` to fall + * back to `nodeAlign` for that node. The column count grows to fit the highest pin. */ + nodeColumn(): ((node: SankeyNode) => number | undefined) | null + nodeColumn(nodeColumn: ((node: SankeyNode) => number | undefined) | null): this + nodeWidth(): number nodeWidth(width: number): this @@ -206,6 +211,7 @@ export function sankeyLayout< let id: (d: SankeyNode) => string | number = (d) => d.index let align: (node: SankeyNode, n: number) => number = sankeyJustify + let column: ((node: SankeyNode) => number | undefined) | null = null let sort: ((a: SankeyNode, b: SankeyNode) => number) | null | undefined let nodesFn: (graph: SankeyInputGraph) => SankeyNode[] = (graph) => graph.nodes as SankeyNode[] let linksFn: (graph: SankeyInputGraph) => SankeyLink[] = (graph) => graph.links as SankeyLink[] @@ -266,6 +272,18 @@ export function sankeyLayout< return id } + function nodeColumnAccessor(): ((node: SankeyNode) => number | undefined) | null + function nodeColumnAccessor(fn: ((node: SankeyNode) => number | undefined) | null): SankeyLayout + function nodeColumnAccessor( + fn?: ((node: SankeyNode) => number | undefined) | null + ): ((node: SankeyNode) => number | undefined) | null | SankeyLayout { + if (fn !== undefined) { + column = fn + return self + } + return column + } + function nodeAlignAccessor(): (node: SankeyNode, n: number) => number function nodeAlignAccessor(fn: (node: SankeyNode, n: number) => number): SankeyLayout function nodeAlignAccessor( @@ -348,6 +366,7 @@ export function sankeyLayout< links: linksAccessor, nodeId: nodeIdAccessor, nodeAlign: nodeAlignAccessor, + nodeColumn: nodeColumnAccessor, nodeSort: nodeSortAccessor, nodeWidth: nodeWidthAccessor, nodePadding: nodePaddingAccessor, @@ -431,11 +450,12 @@ export function sankeyLayout< } function computeNodeLayers({ nodes: nodeList }: { nodes: SankeyNode[] }): SankeyNode[][] { - const x = max(nodeList, (d) => d.depth)! + 1 + const pinned = (node: SankeyNode): number | undefined => column?.(node) + const x = Math.max(max(nodeList, (d) => d.depth)!, max(nodeList, (d) => pinned(d) ?? 0)!) + 1 const kx = x <= 1 ? 0 : (x1 - x0 - dx) / (x - 1) const columns = Array.from({ length: x }, () => [] as SankeyNode[]) for (const node of nodeList) { - const i = Math.max(0, Math.min(x - 1, Math.floor(align(node, x)))) + const i = Math.max(0, Math.min(x - 1, Math.floor(pinned(node) ?? align(node, x)))) node.layer = i node.x0 = x0 + i * kx node.x1 = node.x0 + dx diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 06a98019f845..f6214cd4edc7 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -86,8 +86,10 @@ There is no `series` or `labels`. `var(--…)` colors resolve inside the chart. - `nodeAlign` decides where a flow that ends early sits: `justify` (default) moves terminal nodes to the last column; `left` keeps each node at its own depth, which reads right when columns are stages. `preserveNodeOrder` keeps input order within a column instead of untangling ribbons. + A node with `column` set is pinned there whatever its depth, and the graph grows to fit the highest pin; use it when the data names its own stage, so `columnLabels` stay truthful for flows that end early or start late. - `columnLabels` renders headers above each column and reserves room for them. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. + A label that would print over a larger neighbor's label in the same column is dropped; the tooltip still names that node. `showNodeValues` appends the node value. - Hovering a node lifts its ribbons and dims the rest of the graph; hovering a ribbon lifts just that ribbon. The default tooltip shows the node label or `source → target`, the value, and its share of `layout.total` (the summed value of source nodes with no incoming link). diff --git a/services/mcp/src/ui-apps/components/Paths.stories.tsx b/services/mcp/src/ui-apps/components/Paths.stories.tsx new file mode 100644 index 000000000000..a161a44dbc43 --- /dev/null +++ b/services/mcp/src/ui-apps/components/Paths.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryFn, StoryObj } from '@storybook/react' +import type { ReactElement } from 'react' + +import { McpThemeDecorator } from '@posthog/mcp-ui/storybook/decorator' + +import { PathsVisualizer } from './PathsVisualizer' +import type { PathsResult } from './types' + +// The visualizer sizes its canvas off a ResizeObserver, so give it a definite width — a `w-full` box +// can measure 0 at mount in the headless snapshot runner and paint nothing. +const FixedWidth = (Story: StoryFn): ReactElement => ( +
+ +
+) + +const meta: Meta = { + title: 'MCP Apps/Paths', + decorators: [McpThemeDecorator, FixedWidth], + parameters: { + testOptions: { + skipDarkMode: true, + }, + }, +} +export default meta + +type Story = StoryObj<{}> + +const STORE_PATHS: PathsResult = [ + { + source: '1_https://example.com/', + target: '2_https://example.com/product', + value: 3705, + average_conversion_time: 42000, + }, + { + source: '1_https://example.com/', + target: '2_https://example.com/cart', + value: 1240, + average_conversion_time: 18000, + }, + { + source: '1_https://example.com/', + target: '2_https://example.com/checkout', + value: 303, + average_conversion_time: 9000, + }, + { + source: '2_https://example.com/product', + target: '3_https://example.com/cart', + value: 1596, + average_conversion_time: 65000, + }, + { + source: '2_https://example.com/product', + target: '3_https://example.com/', + value: 555, + average_conversion_time: 31000, + }, + { + source: '2_https://example.com/product', + target: '3_https://example.com/product', + value: 490, + average_conversion_time: 22000, + }, + { + source: '2_https://example.com/cart', + target: '3_https://example.com/checkout', + value: 842, + average_conversion_time: 12000, + }, + { + source: '2_https://example.com/cart', + target: '3_https://example.com/product', + value: 210, + average_conversion_time: 47000, + }, + { + source: '3_https://example.com/cart', + target: '4_https://example.com/checkout', + value: 902, + average_conversion_time: 15000, + }, + { + source: '3_https://example.com/', + target: '4_https://example.com/product', + value: 320, + average_conversion_time: 28000, + }, +] + +export const StoreJourneys: Story = { + render: () => , + name: 'Store page journeys', +} + +export const SingleTransition: Story = { + render: () => ( + + ), + name: 'Single transition', +} + +export const Empty: Story = { + render: () => , + name: 'No path data', +} diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 956319f496a8..5084e96b789d 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -1,26 +1,22 @@ -import type { ReactElement } from 'react' +import { type ReactElement, useMemo } from 'react' import { emptyStateIllustration } from '@posthog/mcp-ui' -import { DataTable, type DataTableProps, Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@posthog/quill' +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@posthog/quill' +import { SankeyChart, TooltipSurface, TooltipSwatch } from '@posthog/quill-charts' +import type { SankeyChartConfig, SankeyLinkInput, SankeyNodeInput, SankeyTooltipContext } from '@posthog/quill-charts' import { ChartHeader } from './ChartHeader' -import type { PathsVisualizerProps } from './types' +import { useMcpChartTheme } from './charts/theme' +import type { PathsResult, PathsResultItem, PathsVisualizerProps } from './types' import { formatDuration, formatNumber } from './utils' const TITLE = 'Paths' -// Edges are sorted by user count and the busiest paths lead, so a truncated -// view stays meaningful; columns are non-sortable to match. -const MAX_ROWS = 20 - -function formatCellValue(value: unknown): string { - if (value === null || value === undefined) { - return '-' - } - if (typeof value === 'number') { - return formatNumber(value) - } - return String(value) +const CHART_CONFIG: SankeyChartConfig = { + nodePadding: 6, + linkOpacity: 0.35, + showNodeValues: true, + valueFormatter: formatNumber, } /** Node keys are `_`; split into the step number and the path/value. */ @@ -33,8 +29,88 @@ function parseNode(key: string): { step: number; path: string } { return { step: Number.isNaN(step) ? 0 : step, path: key.slice(sep + 1) } } +/** Page URLs read as their path in the compact chart; anything else (an event name) stays as is. */ +function nodeLabel(path: string): string { + try { + const url = new URL(path) + return `${url.pathname}${url.search}` || path + } catch { + return path + } +} + +interface PathsGraph { + nodes: SankeyNodeInput[] + links: SankeyLinkInput[] + columnLabels: string[] +} + +/** One node per `_` key, so a page seen at two steps is two nodes that share a label + * and therefore a color. Each node is pinned to its step's column, so a path that ends early or + * an edge whose earlier steps were cut from the result still sit under the right header. */ +function buildPathsGraph(edges: PathsResult): PathsGraph { + const nodes = new Map() + let maxStep = 0 + for (const edge of edges) { + for (const key of [edge.source, edge.target]) { + if (!nodes.has(key)) { + const { step, path } = parseNode(key) + maxStep = Math.max(maxStep, step) + nodes.set(key, { id: key, label: nodeLabel(path), column: Math.max(0, step - 1) }) + } + } + } + const links = edges.map( + (edge): SankeyLinkInput => ({ + source: edge.source, + target: edge.target, + value: edge.value ?? 0, + meta: edge, + }) + ) + const columnLabels = Array.from({ length: maxStep }, (_, i) => `Step ${i + 1}`) + return { nodes: [...nodes.values()], links, columnLabels } +} + +function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext }): ReactElement { + const { hit } = ctx + if (hit.kind === 'node') { + return ( + +
+ + {hit.node.label} +
+
{formatNumber(hit.node.value)} users
+
+ ) + } + const { link } = hit + return ( + +
+ {link.source.label} → {link.target.label} +
+
{formatNumber(link.value)} users
+ {link.meta?.average_conversion_time != null && ( +
{formatDuration(link.meta.average_conversion_time)} on average
+ )} +
+ ) +} + +function renderTooltip(ctx: SankeyTooltipContext): ReactElement { + return +} + export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement { - const edges = Array.isArray(results) ? results : [] + const theme = useMcpChartTheme() + const edges = useMemo(() => (Array.isArray(results) ? results : []), [results]) + const graph = useMemo(() => buildPathsGraph(edges), [edges]) + const config = useMemo( + () => ({ ...CHART_CONFIG, columnLabels: graph.columnLabels }), + [graph.columnLabels] + ) if (edges.length === 0) { return ( @@ -50,51 +126,33 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement ) } - // Each row is an edge between two nodes; sort by user count so the busiest paths lead. - const sorted = [...edges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)) - - const columns = ['Step', 'From', 'To', 'Users', 'Avg. time'] - const rows: unknown[][] = sorted.map((edge) => { - const from = parseNode(edge.source) - const to = parseNode(edge.target) - return [ - `${from.step} → ${to.step}`, - from.path, - to.path, - edge.value ?? 0, - edge.average_conversion_time != null ? formatDuration(edge.average_conversion_time) : '-', - ] - }) - - const displayRows = rows.slice(0, MAX_ROWS) - const hasMore = displayRows.length < rows.length - const tableColumns: DataTableProps['columns'] = columns.map((col, colIndex) => ({ - id: String(colIndex), - header: col, - accessorFn: (row: unknown[]) => row[colIndex], - enableSorting: false, - cell: (info: { getValue: () => unknown }) => formatCellValue(info.getValue()), - })) - - const topUsers = sorted[0]?.value ?? 0 + // Users who start a path: the outflow of the nodes nothing leads into. + const hasIncoming = new Set(edges.map((edge) => edge.target)) + const totalUsers = edges + .filter((edge) => !hasIncoming.has(edge.source)) + .reduce((sum, edge) => sum + (edge.value ?? 0), 0) return ( -
+
- - {hasMore && ( - - Showing {displayRows.length} of {rows.length} transitions - - )} - +
+ + nodes={graph.nodes} + links={graph.links} + theme={theme} + config={config} + tooltip={renderTooltip} + /> +
{formatNumber(edges.length)} path transition - {edges.length === 1 ? '' : 's'} - {topUsers > 0 && ( + {edges.length === 1 ? '' : 's'} across{' '} + {graph.columnLabels.length} step + {graph.columnLabels.length === 1 ? '' : 's'} + {totalUsers > 0 && ( <> {' '} - · busiest carries {formatNumber(topUsers)} users + · {formatNumber(totalUsers)} users start a path )}
From 278208f5f2494574a14621ee21c2aee3aef3eade Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 08:50:05 +0100 Subject: [PATCH 48/80] fix(quill-charts): guard sankey nodeColumn pins against NaN and document monotonicity A non-finite pin silently dropped the node from the render; a pin at or before its source's column drew that link backwards with no error. Guard the NaN case and make the caller's monotonicity obligation explicit in the JSDoc for both the public column field and the layout accessor. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../charts/src/charts/SankeyChart/sankey-data.ts | 3 ++- .../charts/src/charts/SankeyChart/sankey-layout.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index e369312ea0e9..060e8f70b9bf 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -15,7 +15,8 @@ export interface SankeyNodeInput { meta?: Meta /** Pin the node to this zero-based column. Use it when the data carries its own stage (the * step in a paths result), so a flow that ends early or starts late still sits under the right - * `columnLabels` header. Nodes without a pin follow `nodeAlign`. */ + * `columnLabels` header. Nodes without a pin follow `nodeAlign`. Pins must be monotonic with + * the graph's edges — a link whose target column is at or before its source's draws backwards. */ column?: number } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index 1267571f235e..fcfaf0914c0f 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -71,7 +71,9 @@ export interface SankeyLayout, n: number) => number): this /** Pins a node to a column regardless of its depth in the graph; return `undefined` to fall - * back to `nodeAlign` for that node. The column count grows to fit the highest pin. */ + * back to `nodeAlign` for that node. The column count grows to fit the highest pin. The caller + * owns monotonicity: a pin at or before a source feeding it draws that link backwards, with no + * error. */ nodeColumn(): ((node: SankeyNode) => number | undefined) | null nodeColumn(nodeColumn: ((node: SankeyNode) => number | undefined) | null): this @@ -450,7 +452,12 @@ export function sankeyLayout< } function computeNodeLayers({ nodes: nodeList }: { nodes: SankeyNode[] }): SankeyNode[][] { - const pinned = (node: SankeyNode): number | undefined => column?.(node) + // A non-finite pin (e.g. NaN from a caller's bad parse) would otherwise flow into `layer`/`x0` + // and silently drop the node from the render; treat it as unpinned instead. + const pinned = (node: SankeyNode): number | undefined => { + const value = column?.(node) + return value !== undefined && Number.isFinite(value) ? value : undefined + } const x = Math.max(max(nodeList, (d) => d.depth)!, max(nodeList, (d) => pinned(d) ?? 0)!) + 1 const kx = x <= 1 ? 0 : (x1 - x0 - dx) / (x - 1) const columns = Array.from({ length: x }, () => [] as SankeyNode[]) From 790f0d491a235623635149302b0a6f21f7a0a98b Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 08:55:13 +0100 Subject: [PATCH 49/80] fix(quill-charts): guard sankey collision resolution against empty pinned columns A pinned column with nothing naturally landing in it left an empty array; resolveCollisions dereferenced the undefined midpoint node and crashed the chart. Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../src/charts/SankeyChart/sankey-data.test.ts | 15 +++++++++++++++ .../src/charts/SankeyChart/sankey-layout.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 6ff169b75d38..81fffd4315f0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -72,6 +72,21 @@ describe('computeSankeyLayout', () => { expect(layout.columnCount).toBe(5) }) + it('lays out a pinned graph with an empty column between the pins', () => { + // Only two nodes, pinned three columns apart with nothing to naturally fill the columns + // between them: the graph must not crash spacing out a column with zero nodes in it. + const nodes: SankeyNodeInput[] = [ + { id: 'x', column: 0 }, + { id: 'y', column: 3 }, + ] + const links: SankeyLinkInput[] = [{ source: 'x', target: 'y', value: 5 }] + expect(() => layoutOf({ nodes, links, nodeAlign: 'left' })).not.toThrow() + const layout = layoutOf({ nodes, links, nodeAlign: 'left' }) + const columnOf = (id: string): number | undefined => layout.nodes.find((n) => n.id === id)?.column + expect([columnOf('x'), columnOf('y')]).toEqual([0, 3]) + expect(layout.columnCount).toBe(4) + }) + it('resolves node colors by label and defaults link color to the source node', () => { const layout = layoutOf({ nodes: [...NODES.slice(0, 3), { id: 'done', label: 'Completed', color: 'var(--success)' }], diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index fcfaf0914c0f..492ec94f8e6a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -567,6 +567,11 @@ export function sankeyLayout< } function resolveCollisions(nodeList: SankeyNode[], alpha: number): void { + // A pinned column can sit between two others with nothing naturally landing in it, leaving + // it empty; there's nothing to space out. + if (nodeList.length === 0) { + return + } const i = nodeList.length >> 1 const subject = nodeList[i] resolveCollisionsBottomToTop(nodeList, subject.y0 - py, i - 1, alpha) From 975a01e5cc4397b40056d2ff7eb763c675e99105 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 23 Sep 2026 09:01:33 +0100 Subject: [PATCH 50/80] fix(mcp): keep paths labels distinct, cap the drawn edges, and list them for screen readers Generated-By: PostHog Desktop Task-Id: ee561553-a14c-4476-8e6d-14594f9d61b6 --- .../charts/SankeyChart/sankey-data.test.ts | 2 + .../src/charts/SankeyChart/sankey-data.ts | 5 +- .../ui-apps/components/PathsVisualizer.tsx | 100 +++++++++++++----- 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 81fffd4315f0..7f68bc4ddc49 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -85,6 +85,8 @@ describe('computeSankeyLayout', () => { const columnOf = (id: string): number | undefined => layout.nodes.find((n) => n.id === id)?.column expect([columnOf('x'), columnOf('y')]).toEqual([0, 3]) expect(layout.columnCount).toBe(4) + // Headers over the empty columns need an x too, evenly spaced between the pinned ones. + expect(layout.columnX).toEqual([0, 590 / 3, (2 * 590) / 3, 590]) }) it('resolves node colors by label and defaults link color to the source node', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 060e8f70b9bf..ca0e6477124c 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -280,8 +280,9 @@ export function computeSankeyLayout({ }) const columnCount = graph.nodes.reduce((max, node) => Math.max(max, node.layer + 1), 0) - // Mirrors the engine's column spacing, so a column with no node still gets a position. - const columnStep = columnCount <= 1 ? 0 : (plot.plotWidth - effectiveNodeWidth) / (columnCount - 1) + // A pinned column can hold no node, so derive every column's x from the engine's spacing + // rather than from the nodes that happen to land in it, and headers never read a hole. + const columnStep = columnCount > 1 ? (plot.plotWidth - effectiveNodeWidth) / (columnCount - 1) : 0 const columnX = Array.from({ length: columnCount }, (_, column) => plot.plotLeft + column * columnStep) const total = graph.nodes.filter((node) => node.targetLinks.length === 0).reduce((sum, node) => sum + node.value, 0) diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 5084e96b789d..d5967a33e46f 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -12,6 +12,10 @@ import { formatDuration, formatNumber } from './utils' const TITLE = 'Paths' +// The busiest transitions carry the story; past this many the ribbons are too thin to read and +// the layout's iterative relaxation stops being cheap inside an embedded app. +const MAX_EDGES = 60 + const CHART_CONFIG: SankeyChartConfig = { nodePadding: 6, linkOpacity: 0.35, @@ -29,18 +33,30 @@ function parseNode(key: string): { step: number; path: string } { return { step: Number.isNaN(step) ? 0 : step, path: key.slice(sep + 1) } } -/** Page URLs read as their path in the compact chart; anything else (an event name) stays as is. */ -function nodeLabel(path: string): string { +function parseUrl(value: string): URL | null { try { - const url = new URL(path) - return `${url.pathname}${url.search}` || path + return new URL(value) } catch { - return path + return null + } +} + +/** Page URLs read as their path in the compact chart. The host stays when the result spans more + * than one origin, and a hash stays when it looks like a route, so two steps that differ only + * there keep distinct labels. Anything that is not a URL (an event name) stays as is. */ +function nodeLabel(value: string, singleOrigin: boolean): string { + const url = parseUrl(value) + if (!url) { + return value } + const route = url.hash.includes('/') ? url.hash : '' + const path = `${url.pathname}${url.search}${route}` + return singleOrigin ? path || value : `${url.host}${path}` } interface PathsGraph { - nodes: SankeyNodeInput[] + /** Node `meta` is the full value from the result key, for the tooltip. */ + nodes: SankeyNodeInput[] links: SankeyLinkInput[] columnLabels: string[] } @@ -49,17 +65,23 @@ interface PathsGraph { * and therefore a color. Each node is pinned to its step's column, so a path that ends early or * an edge whose earlier steps were cut from the result still sit under the right header. */ function buildPathsGraph(edges: PathsResult): PathsGraph { - const nodes = new Map() - let maxStep = 0 - for (const edge of edges) { - for (const key of [edge.source, edge.target]) { - if (!nodes.has(key)) { - const { step, path } = parseNode(key) - maxStep = Math.max(maxStep, step) - nodes.set(key, { id: key, label: nodeLabel(path), column: Math.max(0, step - 1) }) - } + const keys = new Set(edges.flatMap((edge) => [edge.source, edge.target])) + const origins = new Set() + for (const key of keys) { + const url = parseUrl(parseNode(key).path) + if (url) { + origins.add(url.origin) } } + const singleOrigin = origins.size <= 1 + + const nodes: SankeyNodeInput[] = [] + let maxStep = 0 + for (const key of keys) { + const { step, path } = parseNode(key) + maxStep = Math.max(maxStep, step) + nodes.push({ id: key, label: nodeLabel(path, singleOrigin), meta: path, column: Math.max(0, step - 1) }) + } const links = edges.map( (edge): SankeyLinkInput => ({ source: edge.source, @@ -69,10 +91,10 @@ function buildPathsGraph(edges: PathsResult): PathsGraph { }) ) const columnLabels = Array.from({ length: maxStep }, (_, i) => `Step ${i + 1}`) - return { nodes: [...nodes.values()], links, columnLabels } + return { nodes, links, columnLabels } } -function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext }): ReactElement { +function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext }): ReactElement { const { hit } = ctx if (hit.kind === 'node') { return ( @@ -81,6 +103,9 @@ function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext {hit.node.label}
+ {hit.node.meta && hit.node.meta !== hit.node.label && ( +
{hit.node.meta}
+ )}
{formatNumber(hit.node.value)} users
) @@ -99,20 +124,26 @@ function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext): ReactElement { +function renderTooltip(ctx: SankeyTooltipContext): ReactElement { return } export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement { const theme = useMcpChartTheme() - const edges = useMemo(() => (Array.isArray(results) ? results : []), [results]) + const allEdges = useMemo(() => (Array.isArray(results) ? results : []), [results]) + // Busiest first, so a truncated view keeps the transitions that matter. + const edges = useMemo( + () => [...allEdges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES), + [allEdges] + ) const graph = useMemo(() => buildPathsGraph(edges), [edges]) const config = useMemo( () => ({ ...CHART_CONFIG, columnLabels: graph.columnLabels }), [graph.columnLabels] ) + const labelOf = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node.label])), [graph.nodes]) - if (edges.length === 0) { + if (allEdges.length === 0) { return (
@@ -131,12 +162,13 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement const totalUsers = edges .filter((edge) => !hasIncoming.has(edge.source)) .reduce((sum, edge) => sum + (edge.value ?? 0), 0) + const truncated = edges.length < allEdges.length return (
- + nodes={graph.nodes} links={graph.links} theme={theme} @@ -144,10 +176,30 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement tooltip={renderTooltip} />
+ {/* The canvas has no per-ribbon semantics, so screen readers get the transitions as text. */} +
    + {edges.map((edge) => ( +
  • + {labelOf.get(edge.source)} to {labelOf.get(edge.target)}: {formatNumber(edge.value ?? 0)} users + {edge.average_conversion_time != null + ? `, ${formatDuration(edge.average_conversion_time)} on average` + : ''} +
  • + ))} +
- {formatNumber(edges.length)} path transition - {edges.length === 1 ? '' : 's'} across{' '} - {graph.columnLabels.length} step + {truncated ? ( + <> + Showing the {formatNumber(edges.length)} busiest of{' '} + {formatNumber(allEdges.length)} path transitions + + ) : ( + <> + {formatNumber(edges.length)} path transition + {edges.length === 1 ? '' : 's'} + + )}{' '} + across {graph.columnLabels.length} step {graph.columnLabels.length === 1 ? '' : 's'} {totalUsers > 0 && ( <> From 976ee8e415169dbb34ff1cb867644266f9e9edc9 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:35:41 +0100 Subject: [PATCH 51/80] fix(mcp): share paths transforms with the insight and address review - move step-key parsing, node building, URL labels and the start count into pathsChartTransforms, which the paths insight and the MCP app both use - count path starts on the full result from step 1, not the truncated graph - show the step range on each transition in the tooltip and screen-reader list - reject a Sankey column pin past MAX_SANKEY_COLUMN before it sizes the layout Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/sankey-data.test.ts | 11 +- .../src/charts/SankeyChart/sankey-data.ts | 11 +- .../frontend/insights/paths/pathUtils.ts | 15 +-- .../frontend/insights/paths/pathsChartData.ts | 31 ++--- .../paths/pathsChartTransforms.test.ts | 28 ++++ .../insights/paths/pathsChartTransforms.ts | 124 ++++++++++++++++++ .../frontend/insights/paths/pathsDataLogic.ts | 21 +-- .../ui-apps/components/PathsVisualizer.tsx | 105 ++++----------- 8 files changed, 217 insertions(+), 129 deletions(-) create mode 100644 products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts create mode 100644 products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 7f68bc4ddc49..91442287a166 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -1,4 +1,4 @@ -import { bezierX, bezierY, computeSankeyLayout, sankeyHitAt } from './sankey-data' +import { MAX_SANKEY_COLUMN, bezierX, bezierY, computeSankeyLayout, sankeyHitAt } from './sankey-data' import type { ComputeSankeyLayoutOptions, SankeyLinkInput, SankeyNodeInput } from './sankey-data' const PLOT = { plotLeft: 0, plotTop: 0, plotWidth: 600, plotHeight: 300 } @@ -89,6 +89,15 @@ describe('computeSankeyLayout', () => { expect(layout.columnX).toEqual([0, 590 / 3, (2 * 590) / 3, 590]) }) + it('rejects a pin past the last column before sizing the layout from it', () => { + const nodes: SankeyNodeInput[] = [ + { id: 'x', column: 0 }, + { id: 'y', column: MAX_SANKEY_COLUMN + 1 }, + ] + const links: SankeyLinkInput[] = [{ source: 'x', target: 'y', value: 5 }] + expect(() => layoutOf({ nodes, links, nodeAlign: 'left' })).toThrow('pinned past the last column') + }) + it('resolves node colors by label and defaults link color to the source node', () => { const layout = layoutOf({ nodes: [...NODES.slice(0, 3), { id: 'done', label: 'Completed', color: 'var(--success)' }], diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index ca0e6477124c..44de1df68c16 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -16,7 +16,8 @@ export interface SankeyNodeInput { /** Pin the node to this zero-based column. Use it when the data carries its own stage (the * step in a paths result), so a flow that ends early or starts late still sits under the right * `columnLabels` header. Nodes without a pin follow `nodeAlign`. Pins must be monotonic with - * the graph's edges — a link whose target column is at or before its source's draws backwards. */ + * the graph's edges — a link whose target column is at or before its source's draws backwards. + * A pin above `MAX_SANKEY_COLUMN` throws. */ column?: number } @@ -115,6 +116,9 @@ interface LayoutLinkProps { [key: string]: unknown } +/** The layout allocates every column up to the highest pin, so one stray pin must not size it. */ +export const MAX_SANKEY_COLUMN = 1000 + const ALIGNMENTS = { left: sankeyLeft, right: sankeyRight, @@ -194,6 +198,11 @@ export function computeSankeyLayout({ if (nodeIds.has(node.id)) { throw new Error(`duplicate Sankey node id: ${node.id}`) } + if (node.column !== undefined && node.column > MAX_SANKEY_COLUMN) { + throw new Error( + `Sankey node ${node.id} is pinned past the last column (${MAX_SANKEY_COLUMN}): ${node.column}` + ) + } nodeIds.add(node.id) } for (const link of links) { diff --git a/products/product_analytics/frontend/insights/paths/pathUtils.ts b/products/product_analytics/frontend/insights/paths/pathUtils.ts index 80541c1e027f..87936d1a2884 100644 --- a/products/product_analytics/frontend/insights/paths/pathUtils.ts +++ b/products/product_analytics/frontend/insights/paths/pathUtils.ts @@ -11,6 +11,7 @@ import { PATH_NODE_CARD_OVERLAP_GAP, PATH_NODE_CARD_TOP_OFFSET, } from './constants' +import { parsePathNodeKey, pathUrlLabel } from './pathsChartTransforms' const PATH_NODE_CARD_TOP_ADJUSTMENTS = 33 @@ -48,7 +49,7 @@ export interface PathNodeData { } /** Path result keys carry the step as a `N_` prefix; the rest is the event or URL. */ -export const stripStepPrefix = (name: string): string => name.replace(/(^[0-9]+_)/, '') +export const stripStepPrefix = (name: string): string => parsePathNodeKey(name).name export function getForwardConnectedIndices(startNode: PathNodeData): { nodeIndices: Set @@ -235,16 +236,10 @@ export function pageUrl(d: PathNodeData, display?: boolean, showFullUrls?: boole return name } - try { - const url = new URL(name) - name = incomingDomains.length !== 1 ? url.href.replace(/(^\w+:|^)\/\//, '') : url.pathname + url.search - if (url.hash?.includes('/')) { - name += url.hash - } + const label = pathUrlLabel(name, incomingDomains.length === 1) + if (label !== name) { // Decode URL-encoded characters (e.g., %3C becomes <) to display path cleaning aliases correctly - name = tryDecodeURIComponent(name) - } catch { - // discard if invalid url + name = tryDecodeURIComponent(label) } if (showFullUrls) { diff --git a/products/product_analytics/frontend/insights/paths/pathsChartData.ts b/products/product_analytics/frontend/insights/paths/pathsChartData.ts index ebc9975307be..8a51f48e2d3a 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartData.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartData.ts @@ -3,6 +3,7 @@ import type { SankeyChartLayout, SankeyLinkInput, SankeyNodeInput } from '@posth import { FunnelPathsFilter, PathsFilter, PathsLink } from '~/queries/schema/schema-general' import { FALLBACK_CANVAS_WIDTH } from './constants' +import { buildPathsSankeyGraph } from './pathsChartTransforms' import { PathNodeData, PathTargetLink, isSelectedPathEndpoint, stripStepPrefix } from './pathUtils' import { Paths } from './types' @@ -18,8 +19,8 @@ export interface PathsGraph { links: SankeyLinkInput[] } -/** Chart inputs for a paths result. Node ids are the result's `N_name` keys, which are unique per - * step, so the same page at two steps is two nodes. */ +/** Chart inputs for a paths result, with the start or end point the filter selected in the accent + * color. */ export function buildPathsGraph( paths: Paths, pathsFilter: PathsFilter, @@ -32,23 +33,17 @@ export function buildPathsGraph( hasOutgoing.add(link.source) hasIncoming.add(link.target) } - const nodes = paths.nodes.map((node): SankeyNodeInput => { - const selected = isSelectedPathEndpoint(pathsFilter, funnelPathsFilter, { - name: stripStepPrefix(node.name), - isPathStart: !hasIncoming.has(node.name), - isPathEnd: !hasOutgoing.has(node.name), - }) - return { id: node.name, color: selected ? colors.selectedNode : colors.node } + const { nodes, links } = buildPathsSankeyGraph(paths.links, { + linkColor: colors.link, + nodeColor: (key) => + isSelectedPathEndpoint(pathsFilter, funnelPathsFilter, { + name: stripStepPrefix(key), + isPathStart: !hasIncoming.has(key), + isPathEnd: !hasOutgoing.has(key), + }) + ? colors.selectedNode + : colors.node, }) - const links = paths.links.map( - (link): SankeyLinkInput => ({ - source: link.source, - target: link.target, - value: link.value, - color: colors.link, - meta: link, - }) - ) return { nodes, links } } diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts new file mode 100644 index 000000000000..2cded735e4de --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts @@ -0,0 +1,28 @@ +import { buildPathsSankeyGraph, pathStartUsers } from './pathsChartTransforms' + +describe('pathsChartTransforms', () => { + it('counts path starts from step 1 only, even when a later step has lost its incoming edge', () => { + // A truncated result can keep `2_/pricing -> 3_/signup` without the edge into `2_/pricing`. + const edges = [ + { source: '1_/home', target: '2_/docs', value: 10 }, + { source: '2_/pricing', target: '3_/signup', value: 12 }, + ] + expect(pathStartUsers(edges)).toBe(10) + }) + + it('pins each node to its step and labels URLs by path on a single origin', () => { + const graph = buildPathsSankeyGraph( + [ + { source: '1_https://example.com/home', target: '3_https://example.com/#/app/settings', value: 4 }, + { source: '1_https://example.com/home', target: '2_signed_up', value: 2 }, + ], + { labelUrls: true, pinSteps: true } + ) + expect(graph.nodes.map(({ label, column }) => [label, column])).toEqual([ + ['/home', 0], + ['/#/app/settings', 2], + ['signed_up', 1], + ]) + expect(graph.stepCount).toBe(3) + }) +}) diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts new file mode 100644 index 000000000000..639491356868 --- /dev/null +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts @@ -0,0 +1,124 @@ +import type { SankeyLinkInput, SankeyNodeInput } from '@posthog/quill-charts' + +// This module imports nothing from `~/` or `lib/`, so the MCP UI app bundle, which only resolves +// `products/*` and `@posthog/*`, shares it with the paths insight. + +/** The fields of a paths result row that the insight and the MCP app both read. */ +export interface PathsEdge { + source: string + target: string + value?: number | null + average_conversion_time?: number | null +} + +const STEP_PREFIX = /^(\d+)_/ + +/** Node keys are `_` with the step counted from 1. A key without a step prefix is + * step 0 and keeps its whole text as the name. */ +export function parsePathNodeKey(key: string): { step: number; name: string } { + const match = key.match(STEP_PREFIX) + if (!match) { + return { step: 0, name: key } + } + return { step: Number(match[1]), name: key.slice(match[0].length) } +} + +/** Every node key once, in the order the result first names it. */ +export function pathNodeKeys(edges: PathsEdge[]): string[] { + const keys = new Set() + for (const edge of edges) { + keys.add(edge.source) + keys.add(edge.target) + } + return [...keys] +} + +function parseUrl(value: string): URL | null { + try { + return new URL(value) + } catch { + return null + } +} + +/** The origin of every URL among the node names, so a caller can tell if the paths leave one site. */ +export function pathOrigins(names: string[]): Set { + const origins = new Set() + for (const name of names) { + const url = parseUrl(name) + if (url) { + origins.add(url.origin) + } + } + return origins +} + +/** A page URL as a short label: its path on a single origin, else its host and path. A hash stays + * when it looks like a route, so two steps that differ only there keep distinct labels. A name + * that is not a URL (an event name) comes back unchanged. */ +export function pathUrlLabel(name: string, singleOrigin: boolean): string { + const url = parseUrl(name) + if (!url) { + return name + } + const route = url.hash.includes('/') ? url.hash : '' + const path = `${url.pathname}${url.search}${route}` + return singleOrigin ? path || name : `${url.host}${path}` +} + +/** Users who start a path: the flow out of step 1. Count it on the full result, because a + * truncated one drops the edges that show a later step is not a start. */ +export function pathStartUsers(edges: PathsEdge[]): number { + return edges + .filter((edge) => parsePathNodeKey(edge.source).step === 1) + .reduce((sum, edge) => sum + (edge.value ?? 0), 0) +} + +export interface PathsSankeyGraph { + nodes: SankeyNodeInput[] + links: SankeyLinkInput[] + /** The highest step in the result, so the caller can label each column. */ + stepCount: number +} + +export interface PathsSankeyGraphOptions { + /** Short labels for page URLs. Off for a host that draws its own labels. */ + labelUrls?: boolean + /** Pin each node to its step's column, so a path that ends early or an edge whose earlier steps + * were cut still sits under the right step. */ + pinSteps?: boolean + nodeColor?: (key: string) => string | undefined + linkColor?: string +} + +/** Chart inputs for a paths result: one node per `_` key, so a page seen at two steps + * is two nodes. Node `meta` is the name without its step; link `meta` is the result row. */ +export function buildPathsSankeyGraph( + edges: Edge[], + { labelUrls = false, pinSteps = false, nodeColor, linkColor }: PathsSankeyGraphOptions = {} +): PathsSankeyGraph { + const keys = pathNodeKeys(edges) + const parsed = keys.map((key) => ({ key, ...parsePathNodeKey(key) })) + const singleOrigin = pathOrigins(parsed.map(({ name }) => name)).size <= 1 + + const nodes = parsed.map( + ({ key, step, name }): SankeyNodeInput => ({ + id: key, + label: labelUrls ? pathUrlLabel(name, singleOrigin) : undefined, + color: nodeColor?.(key), + meta: name, + column: pinSteps ? Math.max(0, step - 1) : undefined, + }) + ) + const links = edges.map( + (edge): SankeyLinkInput => ({ + source: edge.source, + target: edge.target, + value: edge.value ?? 0, + color: linkColor, + meta: edge, + }) + ) + const stepCount = parsed.reduce((max, { step }) => Math.max(max, step), 0) + return { nodes, links, stepCount } +} diff --git a/products/product_analytics/frontend/insights/paths/pathsDataLogic.ts b/products/product_analytics/frontend/insights/paths/pathsDataLogic.ts index 78aec890342a..7871b13cdc78 100644 --- a/products/product_analytics/frontend/insights/paths/pathsDataLogic.ts +++ b/products/product_analytics/frontend/insights/paths/pathsDataLogic.ts @@ -32,6 +32,7 @@ import type { PathsV2Query } from '~/queries/schema/schema-general' import { isPathsQuery } from '~/queries/utils' import { FunnelVizType, InsightLogicProps, PathType, PropertyFilterType, PropertyOperator } from '~/types' +import { pathNodeKeys } from './pathsChartTransforms' import { PathNodeData } from './pathUtils' import { Paths, PathsNode } from './types' @@ -171,22 +172,10 @@ export const pathsDataLogic = kea([ ], paths: [ (s) => [s.results], - (results: PathsLink[]): Paths => { - const nodes: Record = {} - for (const path of results) { - if (!nodes[path.source]) { - nodes[path.source] = { name: path.source } - } - if (!nodes[path.target]) { - nodes[path.target] = { name: path.target } - } - } - - return { - nodes: Object.values(nodes), - links: results, - } - }, + (results: PathsLink[]): Paths => ({ + nodes: pathNodeKeys(results).map((name): PathsNode => ({ name })), + links: results, + }), ], taxonomicGroupTypes: [ (s) => [s.pathsFilter], diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index d5967a33e46f..98a0bf046121 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -3,11 +3,17 @@ import { type ReactElement, useMemo } from 'react' import { emptyStateIllustration } from '@posthog/mcp-ui' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@posthog/quill' import { SankeyChart, TooltipSurface, TooltipSwatch } from '@posthog/quill-charts' -import type { SankeyChartConfig, SankeyLinkInput, SankeyNodeInput, SankeyTooltipContext } from '@posthog/quill-charts' +import type { SankeyChartConfig, SankeyTooltipContext } from '@posthog/quill-charts' + +import { + buildPathsSankeyGraph, + parsePathNodeKey, + pathStartUsers, +} from 'products/product_analytics/frontend/insights/paths/pathsChartTransforms' import { ChartHeader } from './ChartHeader' import { useMcpChartTheme } from './charts/theme' -import type { PathsResult, PathsResultItem, PathsVisualizerProps } from './types' +import type { PathsResultItem, PathsVisualizerProps } from './types' import { formatDuration, formatNumber } from './utils' const TITLE = 'Paths' @@ -23,75 +29,9 @@ const CHART_CONFIG: SankeyChartConfig = { valueFormatter: formatNumber, } -/** Node keys are `_`; split into the step number and the path/value. */ -function parseNode(key: string): { step: number; path: string } { - const sep = key.indexOf('_') - if (sep === -1) { - return { step: 0, path: key } - } - const step = Number.parseInt(key.slice(0, sep), 10) - return { step: Number.isNaN(step) ? 0 : step, path: key.slice(sep + 1) } -} - -function parseUrl(value: string): URL | null { - try { - return new URL(value) - } catch { - return null - } -} - -/** Page URLs read as their path in the compact chart. The host stays when the result spans more - * than one origin, and a hash stays when it looks like a route, so two steps that differ only - * there keep distinct labels. Anything that is not a URL (an event name) stays as is. */ -function nodeLabel(value: string, singleOrigin: boolean): string { - const url = parseUrl(value) - if (!url) { - return value - } - const route = url.hash.includes('/') ? url.hash : '' - const path = `${url.pathname}${url.search}${route}` - return singleOrigin ? path || value : `${url.host}${path}` -} - -interface PathsGraph { - /** Node `meta` is the full value from the result key, for the tooltip. */ - nodes: SankeyNodeInput[] - links: SankeyLinkInput[] - columnLabels: string[] -} - -/** One node per `_` key, so a page seen at two steps is two nodes that share a label - * and therefore a color. Each node is pinned to its step's column, so a path that ends early or - * an edge whose earlier steps were cut from the result still sit under the right header. */ -function buildPathsGraph(edges: PathsResult): PathsGraph { - const keys = new Set(edges.flatMap((edge) => [edge.source, edge.target])) - const origins = new Set() - for (const key of keys) { - const url = parseUrl(parseNode(key).path) - if (url) { - origins.add(url.origin) - } - } - const singleOrigin = origins.size <= 1 - - const nodes: SankeyNodeInput[] = [] - let maxStep = 0 - for (const key of keys) { - const { step, path } = parseNode(key) - maxStep = Math.max(maxStep, step) - nodes.push({ id: key, label: nodeLabel(path, singleOrigin), meta: path, column: Math.max(0, step - 1) }) - } - const links = edges.map( - (edge): SankeyLinkInput => ({ - source: edge.source, - target: edge.target, - value: edge.value ?? 0, - meta: edge, - }) - ) - const columnLabels = Array.from({ length: maxStep }, (_, i) => `Step ${i + 1}`) - return { nodes, links, columnLabels } +/** Which steps a transition joins, so a pair of pages that repeats at two stages reads apart. */ +function stepRange(edge: PathsResultItem): string { + return `step ${parsePathNodeKey(edge.source).step} to ${parsePathNodeKey(edge.target).step}` } function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext }): ReactElement { @@ -116,6 +56,7 @@ function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext {link.source.label} → {link.target.label}
+ {link.meta &&
{stepRange(link.meta)}
}
{formatNumber(link.value)} users
{link.meta?.average_conversion_time != null && (
{formatDuration(link.meta.average_conversion_time)} on average
@@ -136,11 +77,12 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement () => [...allEdges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES), [allEdges] ) - const graph = useMemo(() => buildPathsGraph(edges), [edges]) - const config = useMemo( - () => ({ ...CHART_CONFIG, columnLabels: graph.columnLabels }), - [graph.columnLabels] + const graph = useMemo(() => buildPathsSankeyGraph(edges, { labelUrls: true, pinSteps: true }), [edges]) + const columnLabels = useMemo( + () => Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`), + [graph.stepCount] ) + const config = useMemo(() => ({ ...CHART_CONFIG, columnLabels }), [columnLabels]) const labelOf = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node.label])), [graph.nodes]) if (allEdges.length === 0) { @@ -157,11 +99,7 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement ) } - // Users who start a path: the outflow of the nodes nothing leads into. - const hasIncoming = new Set(edges.map((edge) => edge.target)) - const totalUsers = edges - .filter((edge) => !hasIncoming.has(edge.source)) - .reduce((sum, edge) => sum + (edge.value ?? 0), 0) + const totalUsers = pathStartUsers(allEdges) const truncated = edges.length < allEdges.length return ( @@ -180,7 +118,8 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement
    {edges.map((edge) => (
  • - {labelOf.get(edge.source)} to {labelOf.get(edge.target)}: {formatNumber(edge.value ?? 0)} users + {labelOf.get(edge.source)} to {labelOf.get(edge.target)} ({stepRange(edge)}):{' '} + {formatNumber(edge.value ?? 0)} users {edge.average_conversion_time != null ? `, ${formatDuration(edge.average_conversion_time)} on average` : ''} @@ -199,8 +138,8 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement {edges.length === 1 ? '' : 's'} )}{' '} - across {graph.columnLabels.length} step - {graph.columnLabels.length === 1 ? '' : 's'} + across {columnLabels.length} step + {columnLabels.length === 1 ? '' : 's'} {totalUsers > 0 && ( <> {' '} From 4d3ee726fd77f65f159b2eb88406b5f7fe264cb4 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:39:39 +0100 Subject: [PATCH 52/80] fix(mcp): use the default sankey tooltip in the paths app Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui-apps/components/PathsVisualizer.tsx | 40 +------------------ 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 98a0bf046121..7c1af69cf22b 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -2,8 +2,8 @@ import { type ReactElement, useMemo } from 'react' import { emptyStateIllustration } from '@posthog/mcp-ui' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@posthog/quill' -import { SankeyChart, TooltipSurface, TooltipSwatch } from '@posthog/quill-charts' -import type { SankeyChartConfig, SankeyTooltipContext } from '@posthog/quill-charts' +import { SankeyChart } from '@posthog/quill-charts' +import type { SankeyChartConfig } from '@posthog/quill-charts' import { buildPathsSankeyGraph, @@ -34,41 +34,6 @@ function stepRange(edge: PathsResultItem): string { return `step ${parsePathNodeKey(edge.source).step} to ${parsePathNodeKey(edge.target).step}` } -function PathsTooltip({ ctx }: { ctx: SankeyTooltipContext }): ReactElement { - const { hit } = ctx - if (hit.kind === 'node') { - return ( - -
    - - {hit.node.label} -
    - {hit.node.meta && hit.node.meta !== hit.node.label && ( -
    {hit.node.meta}
    - )} -
    {formatNumber(hit.node.value)} users
    -
    - ) - } - const { link } = hit - return ( - -
    - {link.source.label} → {link.target.label} -
    - {link.meta &&
    {stepRange(link.meta)}
    } -
    {formatNumber(link.value)} users
    - {link.meta?.average_conversion_time != null && ( -
    {formatDuration(link.meta.average_conversion_time)} on average
    - )} -
    - ) -} - -function renderTooltip(ctx: SankeyTooltipContext): ReactElement { - return -} - export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement { const theme = useMcpChartTheme() const allEdges = useMemo(() => (Array.isArray(results) ? results : []), [results]) @@ -111,7 +76,6 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement links={graph.links} theme={theme} config={config} - tooltip={renderTooltip} />
{/* The canvas has no per-ribbon semantics, so screen readers get the transitions as text. */} From 7dc99fba9fb0f82f2994efe3425a3aa37f34de3b Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:45:45 +0100 Subject: [PATCH 53/80] fix(quill-charts): keep sankey labels across the last gap from overlapping Labels in the last two columns that face each other at about the same height split the gap between those columns. Grouping nodes by column now appends instead of copying, and the label sets are memoized. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 25 +++++++++++- .../charts/SankeyChart/SankeyNodeLabels.tsx | 40 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 49c660676893..e57d65415e3e 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -3,8 +3,9 @@ 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 { SankeyChart } from './SankeyChart' +import { labelsSharingLastGap } from './SankeyNodeLabels' const THEME: ChartTheme = { colors: ['#1f77b4', '#ff7f0e', '#2ca02c'], backgroundColor: '#ffffff' } @@ -232,4 +233,26 @@ describe('SankeyChart', () => { rerender() 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]) + ) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx index 3989b422204a..88693661c105 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { FONT_FAMILY, truncateToWidth } from '../../utils/text-measure' import { useSankeyLayout } from './sankey-context' @@ -34,7 +34,12 @@ export function labeledNodes(nodes: SankeyNodeDatum[]): Set { const kept = new Set() const byColumn = new Map() for (const node of nodes) { - byColumn.set(node.column, [...(byColumn.get(node.column) ?? []), node]) + const column = byColumn.get(node.column) + if (column) { + column.push(node) + } else { + byColumn.set(node.column, [node]) + } } for (const column of byColumn.values()) { const placed: number[] = [] @@ -49,6 +54,28 @@ export function labeledNodes(nodes: SankeyNodeDatum[]): Set { return kept } +/** Labels in the last two columns that share the gap between those columns at about the same + * height. The penultimate column's labels run right and the last column's run left, so each of + * these gets half the gap. */ +export function labelsSharingLastGap(shown: Set, columnCount: number): Set { + const crowded = new Set() + if (columnCount < 2) { + return crowded + } + const nodes = [...shown] + const left = nodes.filter((node) => node.column === columnCount - 2) + const right = nodes.filter((node) => node.column === columnCount - 1) + for (const a of left) { + for (const b of right) { + if (Math.abs(centerY(a) - centerY(b)) < LABEL_LINE_HEIGHT) { + crowded.add(a) + crowded.add(b) + } + } + } + return crowded +} + /** One label per node, to the right of it; the last column's labels sit to its left so they stay * inside the plot. Labels truncate to the free space before the next column. */ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNodeLabelsProps): React.ReactElement { @@ -56,7 +83,12 @@ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNo const occupiedColumns = layout.columnX.filter((x): x is number => Number.isFinite(x)) const gap = occupiedColumns.length > 1 ? occupiedColumns[1] - occupiedColumns[0] - layout.nodeWidth : Infinity const maxWidth = Math.max(0, gap - LABEL_GAP * 2) - const shownNodes = labeledNodes(layout.nodes) + const sharedMaxWidth = Math.max(0, (gap - LABEL_GAP * 3) / 2) + const shownNodes = useMemo(() => labeledNodes(layout.nodes), [layout.nodes]) + const crowdedNodes = useMemo( + () => labelsSharingLastGap(shownNodes, layout.columnCount), + [shownNodes, layout.columnCount] + ) return ( <> @@ -66,7 +98,7 @@ export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNo } const text = showValues ? `${node.label} ${valueFormatter(node.value)}` : node.label const last = node.column === layout.columnCount - 1 && layout.columnCount > 1 - const shown = truncateToWidth(text, maxWidth, LABEL_FONT) + const shown = truncateToWidth(text, crowdedNodes.has(node) ? sharedMaxWidth : maxWidth, LABEL_FONT) const style: React.CSSProperties = { ...LABEL_STYLE_BASE, color, From a7f56a40d17ef44550c4cb28a62883583d5f398c Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 15:59:08 +0100 Subject: [PATCH 54/80] fix(mcp): bound paths steps to the chart and count path starts honestly - reject Sankey pins that are not whole columns from 0 to MAX_SANKEY_COLUMN, and reset the error boundary when a pin changes - pin and label steps only when they fit the chart's columns - widen the paths app chart past five steps and let it scroll - call step-1 flow path starts, since the query counts paths, not people - parse URL origins only when the graph labels URLs Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/SankeyChart.tsx | 2 +- .../charts/SankeyChart/sankey-data.test.ts | 6 +-- .../src/charts/SankeyChart/sankey-data.ts | 9 ++-- packages/quill/packages/charts/src/index.ts | 1 + .../paths/pathsChartTransforms.test.ts | 4 +- .../insights/paths/pathsChartTransforms.ts | 20 ++++++--- .../ui-apps/components/PathsVisualizer.tsx | 43 ++++++++++++------- 7 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 9d9df99349d0..9cc06256c3bf 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -42,7 +42,7 @@ function graphKey( ): string { // Structured serialization: ids are free-form strings, so a delimiter inside one must not collide. return JSON.stringify([ - nodes.map((node) => node.id), + nodes.map((node) => [node.id, node.column ?? null]), links.map(({ source, target, value }) => [source, target, value]), [config?.nodeWidth, config?.nodePadding, config?.nodeAlign, config?.preserveNodeOrder], ]) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 91442287a166..e6294ecb0c8d 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -89,13 +89,13 @@ describe('computeSankeyLayout', () => { expect(layout.columnX).toEqual([0, 590 / 3, (2 * 590) / 3, 590]) }) - it('rejects a pin past the last column before sizing the layout from it', () => { + it.each([MAX_SANKEY_COLUMN + 1, -1, 1.5, NaN])('rejects a pin of %p instead of moving the node', (column) => { const nodes: SankeyNodeInput[] = [ { id: 'x', column: 0 }, - { id: 'y', column: MAX_SANKEY_COLUMN + 1 }, + { id: 'y', column }, ] const links: SankeyLinkInput[] = [{ source: 'x', target: 'y', value: 5 }] - expect(() => layoutOf({ nodes, links, nodeAlign: 'left' })).toThrow('pinned past the last column') + expect(() => layoutOf({ nodes, links, nodeAlign: 'left' })).toThrow('needs a whole column') }) it('resolves node colors by label and defaults link color to the source node', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 44de1df68c16..9cad798c79c9 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -17,7 +17,7 @@ export interface SankeyNodeInput { * step in a paths result), so a flow that ends early or starts late still sits under the right * `columnLabels` header. Nodes without a pin follow `nodeAlign`. Pins must be monotonic with * the graph's edges — a link whose target column is at or before its source's draws backwards. - * A pin above `MAX_SANKEY_COLUMN` throws. */ + * A pin that is not a whole number from 0 to `MAX_SANKEY_COLUMN` throws. */ column?: number } @@ -198,9 +198,12 @@ export function computeSankeyLayout({ if (nodeIds.has(node.id)) { throw new Error(`duplicate Sankey node id: ${node.id}`) } - if (node.column !== undefined && node.column > MAX_SANKEY_COLUMN) { + if ( + node.column !== undefined && + !(Number.isInteger(node.column) && node.column >= 0 && node.column <= MAX_SANKEY_COLUMN) + ) { throw new Error( - `Sankey node ${node.id} is pinned past the last column (${MAX_SANKEY_COLUMN}): ${node.column}` + `Sankey node ${node.id} needs a whole column from 0 to ${MAX_SANKEY_COLUMN}, got ${node.column}` ) } nodeIds.add(node.id) diff --git a/packages/quill/packages/charts/src/index.ts b/packages/quill/packages/charts/src/index.ts index 0cd03c9e8d3c..79469cc4b82e 100644 --- a/packages/quill/packages/charts/src/index.ts +++ b/packages/quill/packages/charts/src/index.ts @@ -130,6 +130,7 @@ export type { SankeyNodeDatum, SankeyNodeInput, } from './charts/SankeyChart/sankey-data' +export { MAX_SANKEY_COLUMN } from './charts/SankeyChart/sankey-data' export { useSankeyLayout } from './charts/SankeyChart/sankey-context' export type { SankeyLayoutContextValue } from './charts/SankeyChart/sankey-context' // The layout engine on its own, for hosts that draw their own SVG (user paths). diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts index 2cded735e4de..994468ccef85 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts @@ -1,4 +1,4 @@ -import { buildPathsSankeyGraph, pathStartUsers } from './pathsChartTransforms' +import { buildPathsSankeyGraph, pathStartCount } from './pathsChartTransforms' describe('pathsChartTransforms', () => { it('counts path starts from step 1 only, even when a later step has lost its incoming edge', () => { @@ -7,7 +7,7 @@ describe('pathsChartTransforms', () => { { source: '1_/home', target: '2_/docs', value: 10 }, { source: '2_/pricing', target: '3_/signup', value: 12 }, ] - expect(pathStartUsers(edges)).toBe(10) + expect(pathStartCount(edges)).toBe(10) }) it('pins each node to its step and labels URLs by path on a single origin', () => { diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts index 639491356868..1ebca2b4dc56 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts @@ -1,3 +1,4 @@ +import { MAX_SANKEY_COLUMN } from '@posthog/quill-charts' import type { SankeyLinkInput, SankeyNodeInput } from '@posthog/quill-charts' // This module imports nothing from `~/` or `lib/`, so the MCP UI app bundle, which only resolves @@ -66,9 +67,10 @@ export function pathUrlLabel(name: string, singleOrigin: boolean): string { return singleOrigin ? path || name : `${url.host}${path}` } -/** Users who start a path: the flow out of step 1. Count it on the full result, because a +/** Paths that begin at step 1: the flow out of that step. The query counts one path per person + * and session, so this is not a count of distinct people. Count it on the full result, because a * truncated one drops the edges that show a later step is not a start. */ -export function pathStartUsers(edges: PathsEdge[]): number { +export function pathStartCount(edges: PathsEdge[]): number { return edges .filter((edge) => parsePathNodeKey(edge.source).step === 1) .reduce((sum, edge) => sum + (edge.value ?? 0), 0) @@ -77,8 +79,11 @@ export function pathStartUsers(edges: PathsEdge[]): number { export interface PathsSankeyGraph { nodes: SankeyNodeInput[] links: SankeyLinkInput[] - /** The highest step in the result, so the caller can label each column. */ + /** The highest step in the result. */ stepCount: number + /** Whether nodes sit in their step's column, so the caller can label columns by step. False + * when pinning was off or the result has more steps than the chart has columns. */ + stepsPinned: boolean } export interface PathsSankeyGraphOptions { @@ -99,7 +104,9 @@ export function buildPathsSankeyGraph( ): PathsSankeyGraph { const keys = pathNodeKeys(edges) const parsed = keys.map((key) => ({ key, ...parsePathNodeKey(key) })) - const singleOrigin = pathOrigins(parsed.map(({ name }) => name)).size <= 1 + const singleOrigin = labelUrls && pathOrigins(parsed.map(({ name }) => name)).size <= 1 + const stepCount = parsed.reduce((max, { step }) => Math.max(max, step), 0) + const stepsPinned = pinSteps && stepCount - 1 <= MAX_SANKEY_COLUMN const nodes = parsed.map( ({ key, step, name }): SankeyNodeInput => ({ @@ -107,7 +114,7 @@ export function buildPathsSankeyGraph( label: labelUrls ? pathUrlLabel(name, singleOrigin) : undefined, color: nodeColor?.(key), meta: name, - column: pinSteps ? Math.max(0, step - 1) : undefined, + column: stepsPinned ? Math.max(0, step - 1) : undefined, }) ) const links = edges.map( @@ -119,6 +126,5 @@ export function buildPathsSankeyGraph( meta: edge, }) ) - const stepCount = parsed.reduce((max, { step }) => Math.max(max, step), 0) - return { nodes, links, stepCount } + return { nodes, links, stepCount, stepsPinned } } diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 7c1af69cf22b..b34a5054123c 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -8,7 +8,7 @@ import type { SankeyChartConfig } from '@posthog/quill-charts' import { buildPathsSankeyGraph, parsePathNodeKey, - pathStartUsers, + pathStartCount, } from 'products/product_analytics/frontend/insights/paths/pathsChartTransforms' import { ChartHeader } from './ChartHeader' @@ -22,6 +22,8 @@ const TITLE = 'Paths' // the layout's iterative relaxation stops being cheap inside an embedded app. const MAX_EDGES = 60 +const MAX_STEPS_IN_FRAME = 5 + const CHART_CONFIG: SankeyChartConfig = { nodePadding: 6, linkOpacity: 0.35, @@ -43,9 +45,10 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement [allEdges] ) const graph = useMemo(() => buildPathsSankeyGraph(edges, { labelUrls: true, pinSteps: true }), [edges]) + // Step headers only line up when every node sits in its step's column. const columnLabels = useMemo( - () => Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`), - [graph.stepCount] + () => (graph.stepsPinned ? Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) : []), + [graph.stepsPinned, graph.stepCount] ) const config = useMemo(() => ({ ...CHART_CONFIG, columnLabels }), [columnLabels]) const labelOf = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node.label])), [graph.nodes]) @@ -64,26 +67,33 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement ) } - const totalUsers = pathStartUsers(allEdges) + const pathStarts = pathStartCount(allEdges) + // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths + // insight does, so long paths keep readable columns. + const chartWidth = + graph.stepCount > MAX_STEPS_IN_FRAME ? `${(graph.stepCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' const truncated = edges.length < allEdges.length return (
-
- - nodes={graph.nodes} - links={graph.links} - theme={theme} - config={config} - /> +
+ {/* The chart sizes to its parent, so a long path needs a wider parent to scroll. */} +
+ + nodes={graph.nodes} + links={graph.links} + theme={theme} + config={config} + /> +
{/* The canvas has no per-ribbon semantics, so screen readers get the transitions as text. */}
    {edges.map((edge) => (
  • {labelOf.get(edge.source)} to {labelOf.get(edge.target)} ({stepRange(edge)}):{' '} - {formatNumber(edge.value ?? 0)} users + {formatNumber(edge.value ?? 0)} paths {edge.average_conversion_time != null ? `, ${formatDuration(edge.average_conversion_time)} on average` : ''} @@ -102,12 +112,13 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement {edges.length === 1 ? '' : 's'} )}{' '} - across {columnLabels.length} step - {columnLabels.length === 1 ? '' : 's'} - {totalUsers > 0 && ( + across {formatNumber(graph.stepCount)} step + {graph.stepCount === 1 ? '' : 's'} + {pathStarts > 0 && ( <> {' '} - · {formatNumber(totalUsers)} users start a path + · {formatNumber(pathStarts)} path + {pathStarts === 1 ? ' begins' : 's begin'} at step 1 )}
From 4f1be5ffcb00f1b228ceff69528b8125b07ebe91 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 17:15:21 +0100 Subject: [PATCH 55/80] fix(quill-charts): count pinned columns when capping sankey node width Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/sankey-data.test.ts | 13 +++++++++++++ .../charts/src/charts/SankeyChart/sankey-data.ts | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index e6294ecb0c8d..d68d8db2df8d 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -121,6 +121,19 @@ describe('computeSankeyLayout', () => { expect(dense.nodeWidth).toBe(10) }) + it('caps node width by the furthest pin when pins add columns past the longest path', () => { + const pinned = layoutOf({ + nodes: [ + { id: 'x', column: 0 }, + { id: 'y', column: 99 }, + ], + links: [{ source: 'x', target: 'y', value: 1 }], + nodeAlign: 'left', + }) + expect(pinned.columnCount).toBe(100) + expect(pinned.nodeWidth).toBeCloseTo(600 / 99) + }) + it('returns an empty layout without links and throws on a link to a missing node', () => { expect(layoutOf({ links: [] }).nodes).toHaveLength(0) expect(() => layoutOf({ links: [{ source: 'start', target: 'nope', value: 1 }] })).toThrow( diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 9cad798c79c9..f2ae5b2740dd 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -139,6 +139,12 @@ export const EMPTY_SANKEY_LAYOUT: SankeyChartLayout = { nodeWidth: 0, } +/** Columns the layout will use: the longest path, or more when a pin reaches further right. */ +function columnCountOf(nodes: readonly SankeyNodeInput[], links: readonly SankeyLinkInput[]): number { + const pinned = nodes.reduce((max, node) => (node.column === undefined ? max : Math.max(max, node.column + 1)), 0) + return Math.max(longestPathLength(nodes, links), pinned) +} + /** Nodes on the longest path through the graph, which is the column count the layout engine * gives it. A cycle stops the count early; the engine reports the cycle itself. */ function longestPathLength( @@ -222,7 +228,7 @@ export function computeSankeyLayout({ if (!Number.isFinite(nodePadding) || nodePadding < 0) { throw new Error(`Sankey nodePadding must be a finite number of 0 or more: ${nodePadding}`) } - const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, longestPathLength(nodes, links) - 1)) + const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, columnCountOf(nodes, links) - 1)) // The engine mutates its inputs, so hand it fresh objects. const engineNodes: LayoutNodeProps[] = nodes.map((node) => { From cb03a2523f77bb895ec79e9cdb3fd517c33ab559 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 17:22:08 +0100 Subject: [PATCH 56/80] fix(mcp): cap sankey scroll width and node width at the laid-out column count Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/sankey-data.test.ts | 2 +- .../charts/src/charts/SankeyChart/sankey-data.ts | 2 +- services/mcp/src/ui-apps/components/PathsVisualizer.tsx | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index d68d8db2df8d..52410d0b612e 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -131,7 +131,7 @@ describe('computeSankeyLayout', () => { nodeAlign: 'left', }) expect(pinned.columnCount).toBe(100) - expect(pinned.nodeWidth).toBeCloseTo(600 / 99) + expect(pinned.nodeWidth).toBeCloseTo(600 / 100) }) it('returns an empty layout without links and throws on a link to a missing node', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index f2ae5b2740dd..8f0da7c39c58 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -228,7 +228,7 @@ export function computeSankeyLayout({ if (!Number.isFinite(nodePadding) || nodePadding < 0) { throw new Error(`Sankey nodePadding must be a finite number of 0 or more: ${nodePadding}`) } - const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, columnCountOf(nodes, links) - 1)) + const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, columnCountOf(nodes, links))) // The engine mutates its inputs, so hand it fresh objects. const engineNodes: LayoutNodeProps[] = nodes.map((node) => { diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index b34a5054123c..ec9be29a6d1d 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -24,6 +24,10 @@ const MAX_EDGES = 60 const MAX_STEPS_IN_FRAME = 5 +// Bounds the scroll width, so a result with a very high step index cannot size the canvas +// to thousands of percent of the frame. +const MAX_SCROLL_COLUMNS = 25 + const CHART_CONFIG: SankeyChartConfig = { nodePadding: 6, linkOpacity: 0.35, @@ -70,8 +74,9 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement const pathStarts = pathStartCount(allEdges) // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths // insight does, so long paths keep readable columns. - const chartWidth = - graph.stepCount > MAX_STEPS_IN_FRAME ? `${(graph.stepCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' + // Unpinned, the chart lays out its longest path, which the edges bound. + const columnCount = Math.min(graph.stepsPinned ? graph.stepCount : edges.length + 1, MAX_SCROLL_COLUMNS) + const chartWidth = columnCount > MAX_STEPS_IN_FRAME ? `${(columnCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' const truncated = edges.length < allEdges.length return ( From 9816cf2326e8e531b76913a2162023bd8fe51d9f Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 17:25:03 +0100 Subject: [PATCH 57/80] fix(quill-charts): keep sankey headers on the engine's columns and event labels whole - Space column headers from the engine's column count, so an empty last column no longer shifts them. - Round fractional engine pins down and document that pinning is all or nothing. - Treat only http and https names as page URLs, so event names with a colon keep their label. - Size the MCP paths canvas from distinct steps when steps are not pinned. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/sankey-data.test.ts | 20 +++++++++++++++++++ .../src/charts/SankeyChart/sankey-data.ts | 7 ++++--- .../src/charts/SankeyChart/sankey-layout.ts | 9 +++++---- .../paths/pathsChartTransforms.test.ts | 6 +++--- .../insights/paths/pathsChartTransforms.ts | 5 ++++- .../ui-apps/components/PathsVisualizer.tsx | 8 ++++++-- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 52410d0b612e..42fdecdf01ec 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -89,6 +89,26 @@ describe('computeSankeyLayout', () => { expect(layout.columnX).toEqual([0, 590 / 3, (2 * 590) / 3, 590]) }) + it('spaces column headers like the nodes when the last column the engine allots is empty', () => { + // The unpinned chain is five deep, but `b` is pinned back to column 2, so column 4 holds no node. + const nodes: SankeyNodeInput[] = [ + { id: 'a', column: 0 }, + { id: 'u1' }, + { id: 'u2' }, + { id: 'u3' }, + { id: 'b', column: 2 }, + ] + const links: SankeyLinkInput[] = [ + { source: 'a', target: 'u1', value: 5 }, + { source: 'u1', target: 'u2', value: 5 }, + { source: 'u2', target: 'u3', value: 5 }, + { source: 'u3', target: 'b', value: 5 }, + ] + const layout = layoutOf({ nodes, links, nodeAlign: 'left' }) + expect(layout.columnCount).toBe(5) + expect(layout.nodes.map((node) => layout.columnX[node.column])).toEqual(layout.nodes.map((node) => node.x0)) + }) + it.each([MAX_SANKEY_COLUMN + 1, -1, 1.5, NaN])('rejects a pin of %p instead of moving the node', (column) => { const nodes: SankeyNodeInput[] = [ { id: 'x', column: 0 }, diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 8f0da7c39c58..7beae1aa7a14 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -15,7 +15,8 @@ export interface SankeyNodeInput { meta?: Meta /** Pin the node to this zero-based column. Use it when the data carries its own stage (the * step in a paths result), so a flow that ends early or starts late still sits under the right - * `columnLabels` header. Nodes without a pin follow `nodeAlign`. Pins must be monotonic with + * `columnLabels` header. Nodes without a pin follow `nodeAlign` and ignore their neighbours' pins, so + * pin every node or none. Pins must be monotonic with * the graph's edges — a link whose target column is at or before its source's draws backwards. * A pin that is not a whole number from 0 to `MAX_SANKEY_COLUMN` throws. */ column?: number @@ -228,7 +229,8 @@ export function computeSankeyLayout({ if (!Number.isFinite(nodePadding) || nodePadding < 0) { throw new Error(`Sankey nodePadding must be a finite number of 0 or more: ${nodePadding}`) } - const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, columnCountOf(nodes, links))) + const columnCount = columnCountOf(nodes, links) + const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, columnCount)) // The engine mutates its inputs, so hand it fresh objects. const engineNodes: LayoutNodeProps[] = nodes.map((node) => { @@ -297,7 +299,6 @@ export function computeSankeyLayout({ } }) - const columnCount = graph.nodes.reduce((max, node) => Math.max(max, node.layer + 1), 0) // A pinned column can hold no node, so derive every column's x from the engine's spacing // rather than from the nodes that happen to land in it, and headers never read a hole. const columnStep = columnCount > 1 ? (plot.plotWidth - effectiveNodeWidth) / (columnCount - 1) : 0 diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index 492ec94f8e6a..05e819370997 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -71,9 +71,10 @@ export interface SankeyLayout, n: number) => number): this /** Pins a node to a column regardless of its depth in the graph; return `undefined` to fall - * back to `nodeAlign` for that node. The column count grows to fit the highest pin. The caller - * owns monotonicity: a pin at or before a source feeding it draws that link backwards, with no - * error. */ + * back to `nodeAlign` for that node. The column count grows to fit the highest pin, and a fractional + * pin rounds down. The caller owns monotonicity: a pin at or before a source feeding it draws that + * link backwards, with no error. An unpinned node is placed by depth and ignores its neighbours' + * pins, so pin every node or none. */ nodeColumn(): ((node: SankeyNode) => number | undefined) | null nodeColumn(nodeColumn: ((node: SankeyNode) => number | undefined) | null): this @@ -456,7 +457,7 @@ export function sankeyLayout< // and silently drop the node from the render; treat it as unpinned instead. const pinned = (node: SankeyNode): number | undefined => { const value = column?.(node) - return value !== undefined && Number.isFinite(value) ? value : undefined + return value !== undefined && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : undefined } const x = Math.max(max(nodeList, (d) => d.depth)!, max(nodeList, (d) => pinned(d) ?? 0)!) + 1 const kx = x <= 1 ? 0 : (x1 - x0 - dx) / (x - 1) diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts index 994468ccef85..3b7fc974671c 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts @@ -10,18 +10,18 @@ describe('pathsChartTransforms', () => { expect(pathStartCount(edges)).toBe(10) }) - it('pins each node to its step and labels URLs by path on a single origin', () => { + it('pins each node to its step, labels URLs by path on a single origin, and keeps event names whole', () => { const graph = buildPathsSankeyGraph( [ { source: '1_https://example.com/home', target: '3_https://example.com/#/app/settings', value: 4 }, - { source: '1_https://example.com/home', target: '2_signed_up', value: 2 }, + { source: '1_https://example.com/home', target: '2_clicked: signup button', value: 2 }, ], { labelUrls: true, pinSteps: true } ) expect(graph.nodes.map(({ label, column }) => [label, column])).toEqual([ ['/home', 0], ['/#/app/settings', 2], - ['signed_up', 1], + ['clicked: signup button', 1], ]) expect(graph.stepCount).toBe(3) }) diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts index 1ebca2b4dc56..8f8ab3fac46d 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts @@ -34,9 +34,12 @@ export function pathNodeKeys(edges: PathsEdge[]): string[] { return [...keys] } +/** A page URL, or null. `new URL` also accepts any `word:` prefix, so an event named + * `clicked: signup` would otherwise lose its prefix as a pathless URL. */ function parseUrl(value: string): URL | null { try { - return new URL(value) + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' ? url : null } catch { return null } diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index ec9be29a6d1d..18be52b713c9 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -74,8 +74,12 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement const pathStarts = pathStartCount(allEdges) // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths // insight does, so long paths keep readable columns. - // Unpinned, the chart lays out its longest path, which the edges bound. - const columnCount = Math.min(graph.stepsPinned ? graph.stepCount : edges.length + 1, MAX_SCROLL_COLUMNS) + // Unpinned, the chart lays nodes out by depth, and every edge moves one step on, so the + // distinct steps bound its columns where the highest step index does not. + const columnCount = Math.min( + graph.stepsPinned ? graph.stepCount : new Set(graph.nodes.map((node) => parsePathNodeKey(node.id).step)).size, + MAX_SCROLL_COLUMNS + ) const chartWidth = columnCount > MAX_STEPS_IN_FRAME ? `${(columnCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' const truncated = edges.length < allEdges.length From a7ce1e519880e5594015e117fedf2a115d525eed Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 17:27:06 +0100 Subject: [PATCH 58/80] refactor(product-analytics): count path origins with the shared helper pageUrl counted any parseable name as an origin, so an event name source added a "null" origin and switched an http node to host labels. Reuse pathOrigins so the insight and the MCP app agree. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../frontend/insights/paths/pathUtils.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/products/product_analytics/frontend/insights/paths/pathUtils.ts b/products/product_analytics/frontend/insights/paths/pathUtils.ts index 87936d1a2884..048ec84ccf51 100644 --- a/products/product_analytics/frontend/insights/paths/pathUtils.ts +++ b/products/product_analytics/frontend/insights/paths/pathUtils.ts @@ -11,7 +11,7 @@ import { PATH_NODE_CARD_OVERLAP_GAP, PATH_NODE_CARD_TOP_OFFSET, } from './constants' -import { parsePathNodeKey, pathUrlLabel } from './pathsChartTransforms' +import { parsePathNodeKey, pathOrigins, pathUrlLabel } from './pathsChartTransforms' const PATH_NODE_CARD_TOP_ADJUSTMENTS = 33 @@ -217,18 +217,7 @@ export function roundedRect( } export function pageUrl(d: PathNodeData, display?: boolean, showFullUrls?: boolean): string { - const incomingUrls = d.targetLinks - .map((l) => stripStepPrefix(l?.source?.name ?? '')) - .filter((a) => { - try { - new URL(a) - } catch { - return false - } - return a - }) - .map((a) => new URL(a)) - const incomingDomains = Array.from(new Set(incomingUrls.map((url) => url.origin))) + const incomingOrigins = pathOrigins(d.targetLinks.map((l) => stripStepPrefix(l?.source?.name ?? ''))) let name = stripStepPrefix(d.name) @@ -236,7 +225,7 @@ export function pageUrl(d: PathNodeData, display?: boolean, showFullUrls?: boole return name } - const label = pathUrlLabel(name, incomingDomains.length === 1) + const label = pathUrlLabel(name, incomingOrigins.size === 1) if (label !== name) { // Decode URL-encoded characters (e.g., %3C becomes <) to display path cleaning aliases correctly name = tryDecodeURIComponent(label) From 1df0fb0c6fbad59a0cf152d68ef70ec35178c013 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 6 Oct 2026 23:51:07 +0100 Subject: [PATCH 59/80] fix(mcp): count paths summary steps on the full result Co-Authored-By: Claude Opus 5.5 (1M context) --- services/mcp/src/ui-apps/components/PathsVisualizer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 18be52b713c9..aa191ce86a40 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -72,6 +72,8 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement } const pathStarts = pathStartCount(allEdges) + // The busiest edges can leave out the last steps, so count steps on the full result. + const stepCount = allEdges.reduce((max, edge) => Math.max(max, parsePathNodeKey(edge.target).step), 0) // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths // insight does, so long paths keep readable columns. // Unpinned, the chart lays nodes out by depth, and every edge moves one step on, so the @@ -121,8 +123,8 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement {edges.length === 1 ? '' : 's'} )}{' '} - across {formatNumber(graph.stepCount)} step - {graph.stepCount === 1 ? '' : 's'} + across {formatNumber(stepCount)} step + {stepCount === 1 ? '' : 's'} {pathStarts > 0 && ( <> {' '} From 4297e716c53d43188db3c688a371f00104741b2e Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 12:46:28 +0100 Subject: [PATCH 60/80] fix(quill-charts): show sankey names in full on hover and in narrow charts - hovering a node label counts as hovering its node, so a truncated name shows in full in the tooltip - column headers truncate to their span instead of running together - a lastColumnLabels: 'outside' option puts last-column labels in a reserved right margin; cluster journeys use it so Completed and Error read in full Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 37 ++++- .../src/charts/SankeyChart/SankeyChart.tsx | 45 +++-- .../charts/SankeyChart/SankeyColumnLabels.tsx | 44 ++++- .../charts/SankeyChart/SankeyNodeLabels.tsx | 117 +++---------- .../src/charts/SankeyChart/sankey-data.ts | 18 +- .../src/charts/SankeyChart/sankey-labels.ts | 157 ++++++++++++++++++ .../charts/src/charts/SankeyChart/types.ts | 5 + .../SankeyChart/useSankeyInteraction.ts | 16 +- .../packages/charts/src/docs/chart-types.md | 4 +- packages/quill/packages/charts/src/index.ts | 1 + .../clustering/ClusterJourneySankey.tsx | 2 + 11 files changed, 327 insertions(+), 119 deletions(-) create mode 100644 packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index e57d65415e3e..9664fc19a1ac 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -4,8 +4,9 @@ import type { ChartTheme } from '../../core/types' import { getHogChartTooltip, renderHogChart } from '../../testing' import { computeSankeyLayout } from './sankey-data' import type { SankeyLinkInput, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' +import { labelsSharingLastGap, sankeyLabelBoxes } from './sankey-labels' import { SankeyChart } from './SankeyChart' -import { labelsSharingLastGap } from './SankeyNodeLabels' +import { fitHeader } from './SankeyColumnLabels' const THEME: ChartTheme = { colors: ['#1f77b4', '#ff7f0e', '#2ca02c'], backgroundColor: '#ffffff' } @@ -255,4 +256,38 @@ describe('SankeyChart', () => { 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(, { + 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', () => { + 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…') + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 9cc06256c3bf..ee9385c6bde2 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -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' @@ -83,6 +85,7 @@ function SankeyChartInner({ preserveNodeOrder = false, columnLabels, showNodeLabels = true, + lastColumnLabels = 'inside', showNodeValues = false, linkOpacity: configuredLinkOpacity = DEFAULT_LINK_OPACITY, valueFormatter = defaultValueFormatter, @@ -95,12 +98,22 @@ function SankeyChartInner({ const showTooltip = tooltipConfig?.enabled !== false const hasColumnLabels = !!columnLabels && columnLabels.length > 0 + const outsideWidth = useMemo( + () => + showNodeLabels && lastColumnLabels === 'outside' + ? outsideLabelWidth(nodes, links, showNodeValues, valueFormatter) + : 0, + [showNodeLabels, lastColumnLabels, nodes, links, showNodeValues, valueFormatter] + ) + const margins = useMemo(() => { - const computed = hasColumnLabels - ? { ...BASE_MARGINS, top: BASE_MARGINS.top + COLUMN_LABEL_HEIGHT } - : BASE_MARGINS + const computed = { + ...BASE_MARGINS, + top: BASE_MARGINS.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0), + right: BASE_MARGINS.right + outsideWidth, + } return marginsOverride ? applyMarginOverride(computed, marginsOverride) : computed - }, [hasColumnLabels, marginsOverride]) + }, [hasColumnLabels, outsideWidth, marginsOverride]) const { canvasRef, overlayCanvasRef, wrapperRef, dimensions, ctx, overlayCtx } = useChartCanvas({ margins }) @@ -152,8 +165,22 @@ function SankeyChartInner({ [dimensions, layout, linkOpacity, hasEmphasis] ) + const labelBoxes = useMemo( + () => + showNodeLabels + ? sankeyLabelBoxes(layout as SankeyChartLayout, { + showValues: showNodeValues, + valueFormatter, + lastColumnLabels, + outsideWidth, + }) + : [], + [showNodeLabels, layout, showNodeValues, valueFormatter, lastColumnLabels, outsideWidth] + ) + const { hoverIndex, hoverPosition, tooltipCtx, handlers } = useSankeyInteraction({ layout, + labelBoxes, canvasRef, wrapperRef, showTooltip, @@ -260,14 +287,10 @@ function SankeyChartInner({ handlers={handlers} showOverlay={!!dimensions} > - {hasColumnLabels ? : null} - {showNodeLabels ? ( - + {hasColumnLabels ? ( + ) : null} + {showNodeLabels ? : null} {children} {tooltipCtx && showTooltip ? ( 0 && headerWidth(label.slice(0, end) + ELLIPSIS) > maxWidth) { + end-- + } + return end > 0 ? label.slice(0, end).trimEnd() + ELLIPSIS : '' +} + const LABEL_STYLE_BASE: React.CSSProperties = { position: 'absolute', pointerEvents: 'none', top: 0, - fontSize: 10, + fontSize: HEADER_FONT_SIZE, lineHeight: 1.2, textTransform: 'uppercase', letterSpacing: '0.04em', @@ -16,14 +39,20 @@ const LABEL_STYLE_BASE: React.CSSProperties = { export interface SankeyColumnLabelsProps { labels: string[] color: string + /** Free space right of the last column, such as the margin reserved for `outside` labels. */ + trailingRoom?: number } /** Column headers over each node column. The first and last headers align to the outer node edge * instead of centering, so they stay inside the chart. Extra labels beyond the column count are * dropped, so a fixed stage list stays valid when the data has fewer stages. */ -export function SankeyColumnLabels({ labels, color }: SankeyColumnLabelsProps): React.ReactElement { +export function SankeyColumnLabels({ labels, color, trailingRoom = 0 }: SankeyColumnLabelsProps): React.ReactElement { const { layout } = useSankeyLayout() const lastColumn = layout.columnCount - 1 + // Each header owns the span halfway to its neighbors' node centers, so neighbors cannot run together. + const step = layout.columnCount > 1 ? layout.columnX[1] - layout.columnX[0] : Infinity + const middleWidth = step - HEADER_GAP + const edgeWidth = layout.nodeWidth / 2 + step / 2 - HEADER_GAP / 2 return ( <> {Array.from({ length: layout.columnCount }, (_, column) => { @@ -32,8 +61,15 @@ export function SankeyColumnLabels({ labels, color }: SankeyColumnLabelsProps): if (!label || x === undefined) { return null } + // With room past the last column, its header starts over the nodes and runs into that room. + const trailing = column === lastColumn && trailingRoom > 0 + let room = column === 0 || column === lastColumn ? edgeWidth : middleWidth + if (trailing) { + room = layout.nodeWidth + trailingRoom + } + const shown = isFinite(room) ? fitHeader(label, room) : label let placement: React.CSSProperties - if (column === 0) { + if (column === 0 || trailing) { placement = { left: x } } else if (column === lastColumn) { placement = { right: `calc(100% - ${x + layout.nodeWidth}px)` } @@ -46,7 +82,7 @@ export function SankeyColumnLabels({ labels, color }: SankeyColumnLabelsProps): data-attr="hog-chart-sankey-column-label" style={{ ...LABEL_STYLE_BASE, ...placement, color }} > - {label} + {shown}
) })} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx index 88693661c105..362329f1cac0 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -1,14 +1,7 @@ -import React, { useMemo } from 'react' +import React from 'react' -import { FONT_FAMILY, truncateToWidth } from '../../utils/text-measure' -import { useSankeyLayout } from './sankey-context' -import type { SankeyNodeDatum } from './sankey-data' - -const LABEL_FONT_SIZE = 11 -const LABEL_FONT = `${LABEL_FONT_SIZE}px ${FONT_FAMILY}` -const LABEL_GAP = 6 -/** Vertical room one label needs; two label centers closer than this overlap. */ -const LABEL_LINE_HEIGHT = LABEL_FONT_SIZE * 1.2 +import { LABEL_FONT_SIZE } from './sankey-labels' +import type { SankeyLabelBox } from './sankey-labels' const LABEL_STYLE_BASE: React.CSSProperties = { position: 'absolute', @@ -20,97 +13,29 @@ const LABEL_STYLE_BASE: React.CSSProperties = { } export interface SankeyNodeLabelsProps { + boxes: SankeyLabelBox[] color: string - showValues: boolean - valueFormatter: (value: number) => string -} - -const centerY = (node: SankeyNodeDatum): number => (node.y0 + node.y1) / 2 - -/** Nodes whose label fits without covering a neighbor's. Thin nodes stacked in one column would - * print on top of each other, so the larger flow keeps its label and the tooltip still names the - * rest. */ -export function labeledNodes(nodes: SankeyNodeDatum[]): Set { - const kept = new Set() - const byColumn = new Map() - for (const node of nodes) { - const column = byColumn.get(node.column) - if (column) { - column.push(node) - } else { - byColumn.set(node.column, [node]) - } - } - for (const column of byColumn.values()) { - const placed: number[] = [] - for (const node of [...column].sort((a, b) => b.value - a.value)) { - const y = centerY(node) - if (placed.every((other) => Math.abs(other - y) >= LABEL_LINE_HEIGHT)) { - placed.push(y) - kept.add(node) - } - } - } - return kept } -/** Labels in the last two columns that share the gap between those columns at about the same - * height. The penultimate column's labels run right and the last column's run left, so each of - * these gets half the gap. */ -export function labelsSharingLastGap(shown: Set, columnCount: number): Set { - const crowded = new Set() - if (columnCount < 2) { - return crowded - } - const nodes = [...shown] - const left = nodes.filter((node) => node.column === columnCount - 2) - const right = nodes.filter((node) => node.column === columnCount - 1) - for (const a of left) { - for (const b of right) { - if (Math.abs(centerY(a) - centerY(b)) < LABEL_LINE_HEIGHT) { - crowded.add(a) - crowded.add(b) - } - } - } - return crowded -} - -/** One label per node, to the right of it; the last column's labels sit to its left so they stay - * inside the plot. Labels truncate to the free space before the next column. */ -export function SankeyNodeLabels({ color, showValues, valueFormatter }: SankeyNodeLabelsProps): React.ReactElement { - const { layout } = useSankeyLayout() - const occupiedColumns = layout.columnX.filter((x): x is number => Number.isFinite(x)) - const gap = occupiedColumns.length > 1 ? occupiedColumns[1] - occupiedColumns[0] - layout.nodeWidth : Infinity - const maxWidth = Math.max(0, gap - LABEL_GAP * 2) - const sharedMaxWidth = Math.max(0, (gap - LABEL_GAP * 3) / 2) - const shownNodes = useMemo(() => labeledNodes(layout.nodes), [layout.nodes]) - const crowdedNodes = useMemo( - () => labelsSharingLastGap(shownNodes, layout.columnCount), - [shownNodes, layout.columnCount] - ) - +/** Draws the labels `sankeyLabelBoxes` placed. The chart's hit test reads the same boxes, so the + * pointer passes through a label to the chart and hovering it shows its node's tooltip. */ +export function SankeyNodeLabels({ boxes, color }: SankeyNodeLabelsProps): React.ReactElement { return ( <> - {layout.nodes.map((node) => { - if (!shownNodes.has(node)) { - return null - } - const text = showValues ? `${node.label} ${valueFormatter(node.value)}` : node.label - const last = node.column === layout.columnCount - 1 && layout.columnCount > 1 - const shown = truncateToWidth(text, crowdedNodes.has(node) ? sharedMaxWidth : maxWidth, LABEL_FONT) - const style: React.CSSProperties = { - ...LABEL_STYLE_BASE, - color, - top: centerY(node), - ...(last ? { right: `calc(100% - ${node.x0 - LABEL_GAP}px)` } : { left: node.x1 + LABEL_GAP }), - } - return ( -
- {shown} -
- ) - })} + {boxes.map((box) => ( +
+ {box.shown} +
+ ))} ) } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 7beae1aa7a14..a1fdd47578ae 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -314,6 +314,15 @@ export function computeSankeyLayout({ return { nodes: outNodes, links: outLinks, columnCount, columnX, total, nodeWidth: effectiveNodeWidth } } +/** The part of a drawn node label the hit test needs: its node's index and its box. */ +export interface SankeyLabelHitBox { + index: number + x0: number + x1: number + y0: number + y1: number +} + export type SankeyHit = { kind: 'node'; index: number } | { kind: 'link'; index: number } /** Resolves what sits under the cursor: a node box first, then the nearest ribbon whose thickness @@ -321,7 +330,8 @@ export type SankeyHit = { kind: 'node'; index: number } | { kind: 'link'; index: * at the horizontal midpoint), so the test follows the painted shape exactly. */ export function sankeyHitAt( layout: SankeyChartLayout, - cursor: { x: number; y: number } + cursor: { x: number; y: number }, + labels: readonly SankeyLabelHitBox[] = [] ): SankeyHit | null { for (let i = 0; i < layout.nodes.length; i++) { const node = layout.nodes[i] @@ -332,6 +342,12 @@ export function sankeyHitAt( return { kind: 'node', index: i } } } + // A label stands for its node, so pointing at a truncated name shows the node's full tooltip. + for (const label of labels) { + if (cursor.x >= label.x0 && cursor.x <= label.x1 && cursor.y >= label.y0 && cursor.y <= label.y1) { + return { kind: 'node', index: label.index } + } + } let best: { index: number; distance: number } | null = null for (let i = 0; i < layout.links.length; i++) { const link = layout.links[i] diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts new file mode 100644 index 000000000000..5c873e1d94c0 --- /dev/null +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts @@ -0,0 +1,157 @@ +import { FONT_FAMILY, measureLabelWidth, truncateToWidth } from '../../utils/text-measure' +import type { SankeyChartLayout, SankeyLinkInput, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' + +export const LABEL_FONT_SIZE = 11 +export const LABEL_FONT = `${LABEL_FONT_SIZE}px ${FONT_FAMILY}` +export const LABEL_GAP = 6 +/** Vertical room one label needs; two label centers closer than this overlap. */ +export const LABEL_LINE_HEIGHT = LABEL_FONT_SIZE * 1.2 +/** Widest margin `lastColumnLabels: 'outside'` reserves, so long outcome names cannot squeeze the plot. */ +const MAX_OUTSIDE_LABEL_WIDTH = 160 + +/** Where the last column's labels go: `inside` sits them left of the nodes, over the ribbons; + * `outside` sits them right of the nodes in a margin reserved for them. */ +export type SankeyLastColumnLabels = 'inside' | 'outside' + +/** A node label as drawn: its full and truncated text and the box it occupies, in chart pixels. */ +export interface SankeyLabelBox { + /** Index of the node in `layout.nodes`. */ + index: number + text: string + shown: string + side: 'left' | 'right' + x0: number + x1: number + y0: number + y1: number +} + +export interface SankeyLabelOptions { + showValues: boolean + valueFormatter: (value: number) => string + lastColumnLabels: SankeyLastColumnLabels + /** Room right of the last column for `outside` labels. */ + outsideWidth: number +} + +const centerY = (node: SankeyNodeDatum): number => (node.y0 + node.y1) / 2 + +/** Nodes whose label fits without covering a neighbor's. Thin nodes stacked in one column would + * print on top of each other, so the larger flow keeps its label and the tooltip still names the + * rest. */ +export function labeledNodes(nodes: SankeyNodeDatum[]): Set { + const kept = new Set() + const byColumn = new Map() + for (const node of nodes) { + const column = byColumn.get(node.column) + if (column) { + column.push(node) + } else { + byColumn.set(node.column, [node]) + } + } + for (const column of byColumn.values()) { + const placed: number[] = [] + for (const node of [...column].sort((a, b) => b.value - a.value)) { + const y = centerY(node) + if (placed.every((other) => Math.abs(other - y) >= LABEL_LINE_HEIGHT)) { + placed.push(y) + kept.add(node) + } + } + } + return kept +} + +/** Labels in the last two columns that share the gap between those columns at about the same + * height. The penultimate column's labels run right and the last column's run left, so each of + * these gets half the gap. */ +export function labelsSharingLastGap(shown: Set, columnCount: number): Set { + const crowded = new Set() + if (columnCount < 2) { + return crowded + } + const nodes = [...shown] + const left = nodes.filter((node) => node.column === columnCount - 2) + const right = nodes.filter((node) => node.column === columnCount - 1) + for (const a of left) { + for (const b of right) { + if (Math.abs(centerY(a) - centerY(b)) < LABEL_LINE_HEIGHT) { + crowded.add(a) + crowded.add(b) + } + } + } + return crowded +} + +/** The box of every label the chart draws. Labels sit right of their node and truncate to the free + * space before the next column; the last column's labels sit inside, left of the nodes, or + * outside in the reserved margin. Rendering and hit testing both read these boxes, so hovering a + * label hovers its node. */ +export function sankeyLabelBoxes( + layout: SankeyChartLayout, + { showValues, valueFormatter, lastColumnLabels, outsideWidth }: SankeyLabelOptions +): SankeyLabelBox[] { + const columnGap = layout.columnX.length > 1 ? layout.columnX[1] - layout.columnX[0] - layout.nodeWidth : Infinity + const maxWidth = Math.max(0, columnGap - LABEL_GAP * 2) + const sharedMaxWidth = Math.max(0, (columnGap - LABEL_GAP * 3) / 2) + const shownNodes = labeledNodes(layout.nodes) + const crowded = lastColumnLabels === 'inside' ? labelsSharingLastGap(shownNodes, layout.columnCount) : new Set() + const lastColumn = layout.columnCount > 1 ? layout.columnCount - 1 : -1 + + const boxes: SankeyLabelBox[] = [] + layout.nodes.forEach((node, index) => { + if (!shownNodes.has(node)) { + return + } + const text = showValues ? `${node.label} ${valueFormatter(node.value)}` : node.label + const inLastColumn = node.column === lastColumn + let room = crowded.has(node) ? sharedMaxWidth : maxWidth + if (inLastColumn && lastColumnLabels === 'outside') { + room = Math.max(0, outsideWidth - LABEL_GAP) + } + const shown = truncateToWidth(text, isFinite(room) ? room : 0, LABEL_FONT) + const width = measureLabelWidth(shown, LABEL_FONT) + const side = inLastColumn && lastColumnLabels === 'inside' ? 'left' : 'right' + const x0 = side === 'right' ? node.x1 + LABEL_GAP : node.x0 - LABEL_GAP - width + const y = centerY(node) + boxes.push({ + index, + text, + shown, + side, + x0, + x1: x0 + width, + y0: y - LABEL_LINE_HEIGHT / 2, + y1: y + LABEL_LINE_HEIGHT / 2, + }) + }) + return boxes +} + +/** Width to reserve right of the plot for `outside` labels: the widest sink-node label, capped. + * Measured from the inputs because the margin must be known before the layout runs. */ +export function outsideLabelWidth( + nodes: readonly SankeyNodeInput[], + links: readonly SankeyLinkInput[], + showValues: boolean, + valueFormatter: (value: number) => string +): number { + const inflow = new Map() + const hasOutgoing = new Set() + for (const link of links) { + hasOutgoing.add(link.source) + inflow.set(link.target, (inflow.get(link.target) ?? 0) + link.value) + } + let widest = 0 + for (const node of nodes) { + if (hasOutgoing.has(node.id)) { + continue + } + const label = node.label ?? node.id + const text = showValues ? `${label} ${valueFormatter(inflow.get(node.id) ?? 0)}` : label + widest = Math.max(widest, measureLabelWidth(text, LABEL_FONT)) + } + return widest === 0 ? 0 : Math.min(MAX_OUTSIDE_LABEL_WIDTH, Math.ceil(widest) + LABEL_GAP) +} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index 6a1497df1ef0..ad6343b9165d 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -2,6 +2,7 @@ import type React from 'react' import type { ChartMargins, ChartTheme, TooltipContext } from '../../core/types' import type { SankeyLinkDatum, SankeyLinkInput, SankeyNodeAlign, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' +import type { SankeyLastColumnLabels } from './sankey-labels' export type SankeyTooltipHit = | { kind: 'node'; node: SankeyNodeDatum } @@ -36,6 +37,10 @@ export interface SankeyChartConfig { columnLabels?: string[] /** Defaults to true. */ showNodeLabels?: boolean + /** Where the last column's labels sit. `inside` (default) puts them left of the nodes, over the + * ribbons; `outside` puts them right of the nodes in a reserved margin, so names in the last + * column read in full on a narrow chart. */ + lastColumnLabels?: SankeyLastColumnLabels /** Append the node's value to its label. Defaults to false. */ showNodeValues?: boolean /** Resting ribbon opacity, 0..1. Defaults to 0.4. */ diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 0a19cf6ed785..9468a7a6d648 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -6,7 +6,7 @@ import { useTapTracking } from '../../core/hooks/useTapTracking' import { useTooltipLifecycle } from '../../core/hooks/useTooltipLifecycle' import type { Series, TooltipContext } from '../../core/types' import { hitToHoverIndex, hoverIndexToHit, sankeyHitAt } from './sankey-data' -import type { SankeyChartLayout, SankeyHit, SankeyLinkDatum, SankeyNodeDatum } from './sankey-data' +import type { SankeyChartLayout, SankeyHit, SankeyLabelHitBox, SankeyLinkDatum, SankeyNodeDatum } from './sankey-data' import type { SankeyTooltipContext, SankeyTooltipHit } from './types' interface UseSankeyInteractionOptions { @@ -17,6 +17,8 @@ interface UseSankeyInteractionOptions { onNodeClick?: (node: SankeyNodeDatum) => void onLinkClick?: (link: SankeyLinkDatum) => void onHoverChange?: (hit: SankeyTooltipHit | null) => void + /** Drawn node labels; the pointer over one counts as over its node. */ + labelBoxes?: readonly SankeyLabelHitBox[] } function hitKey(hit: SankeyHit | null): string | null { @@ -88,9 +90,11 @@ export function useSankeyInteraction({ onNodeClick, onLinkClick, onHoverChange, + labelBoxes, }: UseSankeyInteractionOptions): UseSankeyInteractionResult { type Ctx = SankeyTooltipContext const layoutRef = useLatest(layout) + const labelBoxesRef = useLatest(labelBoxes) const onHoverChangeRef = useLatest(onHoverChange) // The last hit reported to the host, so a cursor sweep inside one ribbon reports it once. const reportedHitRef = useRef(null) @@ -179,7 +183,7 @@ export function useSankeyInteraction({ } const rect = e.currentTarget.getBoundingClientRect() const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } - const hit = sankeyHitAt(current, cursor) + const hit = sankeyHitAt(current, cursor, labelBoxesRef.current) if (!hit) { clearTooltip() reportHover(null) @@ -187,7 +191,7 @@ export function useSankeyInteraction({ } showHit(hit, cursor) }, - [layoutRef, showHit, clearTooltip] + [layoutRef, labelBoxesRef, showHit, clearTooltip, reportHover] ) const onMouseLeave = useCallback(() => { @@ -212,9 +216,10 @@ export function useSankeyInteraction({ const current = layoutRef.current const rect = e.currentTarget.getBoundingClientRect() const cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top } - let hit = sankeyHitAt(current, cursor) ?? hoverIndexToHit(current, hoverIndexRef.current) + let hit = + sankeyHitAt(current, cursor, labelBoxesRef.current) ?? hoverIndexToHit(current, hoverIndexRef.current) if (lastPointerTypeRef.current === 'touch') { - hit = sankeyHitAt(current, cursor) + hit = sankeyHitAt(current, cursor, labelBoxesRef.current) if (!hit) { clearTooltip() reportHover(null) @@ -239,6 +244,7 @@ export function useSankeyInteraction({ }, [ layoutRef, + labelBoxesRef, hoverIndexRef, lastPointerTypeRef, tapDownTooltipIndexRef, diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index f6214cd4edc7..0e330cd5688b 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -87,8 +87,10 @@ There is no `series` or `labels`. - `nodeAlign` decides where a flow that ends early sits: `justify` (default) moves terminal nodes to the last column; `left` keeps each node at its own depth, which reads right when columns are stages. `preserveNodeOrder` keeps input order within a column instead of untangling ribbons. A node with `column` set is pinned there whatever its depth, and the graph grows to fit the highest pin; use it when the data names its own stage, so `columnLabels` stay truthful for flows that end early or start late. -- `columnLabels` renders headers above each column and reserves room for them. +- `columnLabels` renders headers above each column and reserves room for them; a header too wide for its column truncates. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. + `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px), so names in that column read in full on a narrow chart. + Hovering a label counts as hovering its node, so a truncated name shows in full in the tooltip. A label that would print over a larger neighbor's label in the same column is dropped; the tooltip still names that node. `showNodeValues` appends the node value. - Hovering a node lifts its ribbons and dims the rest of the graph; hovering a ribbon lifts just that ribbon. diff --git a/packages/quill/packages/charts/src/index.ts b/packages/quill/packages/charts/src/index.ts index 79469cc4b82e..0ffebcf6af32 100644 --- a/packages/quill/packages/charts/src/index.ts +++ b/packages/quill/packages/charts/src/index.ts @@ -133,6 +133,7 @@ export type { export { MAX_SANKEY_COLUMN } from './charts/SankeyChart/sankey-data' export { useSankeyLayout } from './charts/SankeyChart/sankey-context' export type { SankeyLayoutContextValue } from './charts/SankeyChart/sankey-context' +export type { SankeyLastColumnLabels } from './charts/SankeyChart/sankey-labels' // The layout engine on its own, for hosts that draw their own SVG (user paths). export { sankeyCenter, diff --git a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx index d1468a47a0dd..65b7c3381b8d 100644 --- a/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx +++ b/products/mcp_analytics/frontend/clustering/ClusterJourneySankey.tsx @@ -29,6 +29,8 @@ const CHART_CONFIG: SankeyChartConfig = { nodeAlign: 'left', preserveNodeOrder: true, linkOpacity: 0.45, + // Outcome names sit in their own margin, so Completed and Error read in full on a narrow panel. + lastColumnLabels: 'outside', } function nodeFill(kind: JourneyNodeKind, theme: ChartTheme): string { From 5a6e9b40dce3f1e18e9c46d85f146e7b9a4e3720 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 14:22:48 +0100 Subject: [PATCH 61/80] fix(quill-charts): size sankey label room from the last column and keep full names - outside labels reserve margin only for sinks the layout puts in the last column, and a caller right margin adds to that room - labels with no room are skipped instead of drawn in full over the next column - truncated headers and node labels keep the full name for screen readers - header truncation uses a binary search, and a long last header can use the span left of its nodes - the error boundary resets when a NaN pin is removed - the MCP paths app narrows node gaps for a dense column so nodes keep their height Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 34 ++++++++++++++- .../src/charts/SankeyChart/SankeyChart.tsx | 17 ++++---- .../charts/SankeyChart/SankeyColumnLabels.tsx | 34 +++++++++------ .../charts/SankeyChart/SankeyNodeLabels.tsx | 14 ++++++- .../src/charts/SankeyChart/sankey-data.ts | 42 ++++++++++++++++--- .../src/charts/SankeyChart/sankey-labels.ts | 23 +++++++--- .../charts/src/charts/SankeyChart/types.ts | 3 +- .../packages/charts/src/docs/chart-types.md | 2 +- .../ui-apps/components/PathsVisualizer.tsx | 20 ++++++++- 9 files changed, 151 insertions(+), 38 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 9664fc19a1ac..146a3ef81a71 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -4,7 +4,7 @@ import type { ChartTheme } from '../../core/types' import { getHogChartTooltip, renderHogChart } from '../../testing' import { computeSankeyLayout } from './sankey-data' import type { SankeyLinkInput, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' -import { labelsSharingLastGap, sankeyLabelBoxes } from './sankey-labels' +import { labelsSharingLastGap, outsideLabelWidth, sankeyLabelBoxes } from './sankey-labels' import { SankeyChart } from './SankeyChart' import { fitHeader } from './SankeyColumnLabels' @@ -222,6 +222,7 @@ describe('SankeyChart', () => { broken: { links: [LINKS[0], { source: 'a', target: 'missing', value: 12 }] }, }, { name: 'the node padding', broken: { config: { nodePadding: -1 } } }, + { name: 'a NaN pin', broken: { nodes: [{ ...NODES[0], column: NaN }, ...NODES.slice(1)] } }, ])('recovers from an invalid graph when only $name is corrected', ({ broken }) => { jest.spyOn(console, 'error').mockImplementation(() => {}) const onError = jest.fn() @@ -290,4 +291,35 @@ describe('SankeyChart', () => { 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) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index ee9385c6bde2..d351f9126e64 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -43,8 +43,9 @@ 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. return JSON.stringify([ - nodes.map((node) => [node.id, node.column ?? null]), + nodes.map((node) => [node.id, String(node.column)]), links.map(({ source, target, value }) => [source, target, value]), [config?.nodeWidth, config?.nodePadding, config?.nodeAlign, config?.preserveNodeOrder], ]) @@ -101,18 +102,16 @@ function SankeyChartInner({ const outsideWidth = useMemo( () => showNodeLabels && lastColumnLabels === 'outside' - ? outsideLabelWidth(nodes, links, showNodeValues, valueFormatter) + ? outsideLabelWidth(nodes, links, nodeAlign, showNodeValues, valueFormatter) : 0, - [showNodeLabels, lastColumnLabels, nodes, links, showNodeValues, valueFormatter] + [showNodeLabels, lastColumnLabels, nodes, links, nodeAlign, showNodeValues, valueFormatter] ) const margins = useMemo(() => { - const computed = { - ...BASE_MARGINS, - top: BASE_MARGINS.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0), - right: BASE_MARGINS.right + outsideWidth, - } - return marginsOverride ? applyMarginOverride(computed, marginsOverride) : computed + const computed = { ...BASE_MARGINS, top: BASE_MARGINS.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0) } + const applied = marginsOverride ? applyMarginOverride(computed, marginsOverride) : computed + // `outside` labels are sized for this room, so an override cannot take it away from them. + return { ...applied, right: applied.right + outsideWidth } }, [hasColumnLabels, outsideWidth, marginsOverride]) const { canvasRef, overlayCanvasRef, wrapperRef, dimensions, ctx, overlayCtx } = useChartCanvas({ margins }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx index 109f9ff06070..3cc73d9f3891 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyColumnLabels.tsx @@ -2,6 +2,7 @@ import React from 'react' import { FONT_FAMILY, ELLIPSIS, measureLabelWidth } from '../../utils/text-measure' import { useSankeyLayout } from './sankey-context' +import { TruncatedText } from './SankeyNodeLabels' const HEADER_FONT_SIZE = 10 const HEADER_FONT = `${HEADER_FONT_SIZE}px ${FONT_FAMILY}` @@ -18,11 +19,17 @@ export function fitHeader(label: string, maxWidth: number): string { if (headerWidth(label) <= maxWidth) { return label } - let end = label.length - while (end > 0 && headerWidth(label.slice(0, end) + ELLIPSIS) > maxWidth) { - end-- + let low = 0 + let high = label.length - 1 + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (headerWidth(label.slice(0, mid).trimEnd() + ELLIPSIS) <= maxWidth) { + low = mid + } else { + high = mid - 1 + } } - return end > 0 ? label.slice(0, end).trimEnd() + ELLIPSIS : '' + return low > 0 ? label.slice(0, low).trimEnd() + ELLIPSIS : '' } const LABEL_STYLE_BASE: React.CSSProperties = { @@ -61,28 +68,31 @@ export function SankeyColumnLabels({ labels, color, trailingRoom = 0 }: SankeyCo if (!label || x === undefined) { return null } - // With room past the last column, its header starts over the nodes and runs into that room. - const trailing = column === lastColumn && trailingRoom > 0 let room = column === 0 || column === lastColumn ? edgeWidth : middleWidth - if (trailing) { - room = layout.nodeWidth + trailingRoom - } - const shown = isFinite(room) ? fitHeader(label, room) : label let placement: React.CSSProperties - if (column === 0 || trailing) { + if (column === lastColumn && trailingRoom > 0) { + // With room past the last column, the header starts over the nodes and runs into + // that room. A header too long for it ends at the room's far edge instead, so it + // also gets the free span left of the nodes. + const rightOfNodes = layout.nodeWidth + trailingRoom + const fitsRight = headerWidth(label) <= rightOfNodes + room = fitsRight ? rightOfNodes : edgeWidth + trailingRoom + placement = fitsRight ? { left: x } : { right: `calc(100% - ${x + rightOfNodes}px)` } + } else if (column === 0) { placement = { left: x } } else if (column === lastColumn) { placement = { right: `calc(100% - ${x + layout.nodeWidth}px)` } } else { placement = { left: x + layout.nodeWidth / 2, transform: 'translateX(-50%)' } } + const shown = isFinite(room) ? fitHeader(label, room) : label return (
- {shown} +
) })} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx index 362329f1cac0..12d496b935c1 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyNodeLabels.tsx @@ -12,6 +12,18 @@ const LABEL_STYLE_BASE: React.CSSProperties = { transform: 'translateY(-50%)', } +export function TruncatedText({ text, shown }: { text: string; shown: string }): React.ReactElement { + if (shown === text) { + return <>{text} + } + return ( + <> + + {text} + + ) +} + export interface SankeyNodeLabelsProps { boxes: SankeyLabelBox[] color: string @@ -33,7 +45,7 @@ export function SankeyNodeLabels({ boxes, color }: SankeyNodeLabelsProps): React ...(box.side === 'right' ? { left: box.x0 } : { right: `calc(100% - ${box.x1}px)` }), }} > - {box.shown} +
))} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index a1fdd47578ae..fc882dea331d 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -147,11 +147,20 @@ function columnCountOf(nodes: readonly SankeyNodeInput[], links: readon } /** Nodes on the longest path through the graph, which is the column count the layout engine - * gives it. A cycle stops the count early; the engine reports the cycle itself. */ + * gives it. */ function longestPathLength( nodes: readonly SankeyNodeInput[], links: readonly SankeyLinkInput[] ): number { + return Math.max(1, ...nodeDepths(nodes, links).values()) +} + +/** Each node's 1-based depth: the nodes on the longest path that ends at it. A cycle stops the + * count early; the engine reports the cycle itself. */ +function nodeDepths( + nodes: readonly SankeyNodeInput[], + links: readonly SankeyLinkInput[] +): Map { const incoming = new Map(nodes.map((node) => [node.id, 0])) const outgoing = new Map() for (const link of links) { @@ -163,14 +172,12 @@ function longestPathLength( outgoing.set(link.source, [link.target]) } } - const depth = new Map() + const depth = new Map(nodes.map((node) => [node.id, 1])) const ready = nodes.filter((node) => incoming.get(node.id) === 0).map((node) => node.id) - let longest = 1 for (let id = ready.pop(); id !== undefined; id = ready.pop()) { const next = (depth.get(id) ?? 1) + 1 for (const target of outgoing.get(id) ?? []) { depth.set(target, Math.max(depth.get(target) ?? 1, next)) - longest = Math.max(longest, next) const remaining = (incoming.get(target) ?? 0) - 1 incoming.set(target, remaining) if (remaining === 0) { @@ -178,7 +185,32 @@ function longestPathLength( } } } - return longest + return depth +} + +/** Ids of the flow-ending nodes the layout puts in its last column. Known before the layout runs, + * so the chart can size the right margin for their labels first. */ +export function lastColumnSinkIds( + nodes: readonly SankeyNodeInput[], + links: readonly SankeyLinkInput[], + nodeAlign: SankeyNodeAlign +): Set { + const hasOutgoing = new Set(links.map((link) => link.source)) + const lastColumn = columnCountOf(nodes, links) - 1 + const depths = nodeDepths(nodes, links) + // `justify` and `right` move every sink to the last column; `left` and `center` keep it at its depth. + const sinksMoveLast = nodeAlign === 'justify' || nodeAlign === 'right' + const ids = new Set() + for (const node of nodes) { + if (hasOutgoing.has(node.id)) { + continue + } + const column = node.column ?? (sinksMoveLast ? lastColumn : (depths.get(node.id) ?? 1) - 1) + if (column === lastColumn) { + ids.add(node.id) + } + } + return ids } /** Lays the graph out inside `plot`. Pure: safe to call from a memo or a test. Throws when a link diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts index 5c873e1d94c0..125dde1c10f7 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts @@ -1,5 +1,12 @@ import { FONT_FAMILY, measureLabelWidth, truncateToWidth } from '../../utils/text-measure' -import type { SankeyChartLayout, SankeyLinkInput, SankeyNodeDatum, SankeyNodeInput } from './sankey-data' +import { lastColumnSinkIds } from './sankey-data' +import type { + SankeyChartLayout, + SankeyLinkInput, + SankeyNodeAlign, + SankeyNodeDatum, + SankeyNodeInput, +} from './sankey-data' export const LABEL_FONT_SIZE = 11 export const LABEL_FONT = `${LABEL_FONT_SIZE}px ${FONT_FAMILY}` @@ -111,7 +118,11 @@ export function sankeyLabelBoxes( if (inLastColumn && lastColumnLabels === 'outside') { room = Math.max(0, outsideWidth - LABEL_GAP) } - const shown = truncateToWidth(text, isFinite(room) ? room : 0, LABEL_FONT) + // A narrow chart can leave a label no room at all; drawing it would cover the next column. + if (room <= 0) { + return + } + const shown = isFinite(room) ? truncateToWidth(text, room, LABEL_FONT) : text const width = measureLabelWidth(shown, LABEL_FONT) const side = inLastColumn && lastColumnLabels === 'inside' ? 'left' : 'right' const x0 = side === 'right' ? node.x1 + LABEL_GAP : node.x0 - LABEL_GAP - width @@ -130,23 +141,23 @@ export function sankeyLabelBoxes( return boxes } -/** Width to reserve right of the plot for `outside` labels: the widest sink-node label, capped. +/** Width to reserve right of the plot for `outside` labels: the widest last-column label, capped. * Measured from the inputs because the margin must be known before the layout runs. */ export function outsideLabelWidth( nodes: readonly SankeyNodeInput[], links: readonly SankeyLinkInput[], + nodeAlign: SankeyNodeAlign, showValues: boolean, valueFormatter: (value: number) => string ): number { + const lastColumn = lastColumnSinkIds(nodes, links, nodeAlign) const inflow = new Map() - const hasOutgoing = new Set() for (const link of links) { - hasOutgoing.add(link.source) inflow.set(link.target, (inflow.get(link.target) ?? 0) + link.value) } let widest = 0 for (const node of nodes) { - if (hasOutgoing.has(node.id)) { + if (!lastColumn.has(node.id)) { continue } const label = node.label ?? node.id diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index ad6343b9165d..f401cc357a44 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -54,7 +54,8 @@ export interface SankeyChartConfig { * anchors it to the hovered node's right edge or the ribbon's midpoint. */ placement?: 'follow-data' | 'top' | 'cursor' } - /** Per-side margin overrides. Should be referentially stable. */ + /** Per-side margin overrides. Should be referentially stable. `outside` labels add their room to + * the right margin on top of an override. */ margins?: Partial } diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 0e330cd5688b..b351038695ce 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -89,7 +89,7 @@ There is no `series` or `labels`. A node with `column` set is pinned there whatever its depth, and the graph grows to fit the highest pin; use it when the data names its own stage, so `columnLabels` stay truthful for flows that end early or start late. - `columnLabels` renders headers above each column and reserves room for them; a header too wide for its column truncates. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. - `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px), so names in that column read in full on a narrow chart. + `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px), so names in that column read in full on a narrow chart. The margin counts only the nodes that land in the last column under `nodeAlign`, and it adds to a consumer `margins.right` rather than being replaced by it. Hovering a label counts as hovering its node, so a truncated name shows in full in the tooltip. A label that would print over a larger neighbor's label in the same column is dropped; the tooltip still names that node. `showNodeValues` appends the node value. diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index aa191ce86a40..8e675cb9ae2e 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -28,8 +28,12 @@ const MAX_STEPS_IN_FRAME = 5 // to thousands of percent of the frame. const MAX_SCROLL_COLUMNS = 25 +const NODE_PADDING = 6 +// Total gap one column may take out of the h-80 chart's plot, under half its height. When a dense +// column's gaps fill the plot, the layout engine draws its nodes and ribbons with no height. +const MAX_COLUMN_PADDING = 120 + const CHART_CONFIG: SankeyChartConfig = { - nodePadding: 6, linkOpacity: 0.35, showNodeValues: true, valueFormatter: formatNumber, @@ -54,7 +58,19 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement () => (graph.stepsPinned ? Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) : []), [graph.stepsPinned, graph.stepCount] ) - const config = useMemo(() => ({ ...CHART_CONFIG, columnLabels }), [columnLabels]) + const nodePadding = useMemo(() => { + const perStep = new Map() + for (const node of graph.nodes) { + const step = parsePathNodeKey(node.id).step + perStep.set(step, (perStep.get(step) ?? 0) + 1) + } + const densest = Math.max(1, ...perStep.values()) + return densest > 1 ? Math.min(NODE_PADDING, MAX_COLUMN_PADDING / (densest - 1)) : NODE_PADDING + }, [graph.nodes]) + const config = useMemo( + () => ({ ...CHART_CONFIG, columnLabels, nodePadding }), + [columnLabels, nodePadding] + ) const labelOf = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node.label])), [graph.nodes]) if (allEdges.length === 0) { From 8f332c8db2d2305bfa67b7dfc112f7c041cdd4cb Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 14:25:29 +0100 Subject: [PATCH 62/80] fix(quill-charts): reject bad sankey pins before the chart has a size - validate nodes, links and sizing before the empty-layout return, so an invalid pin throws on an unsized or linkless chart too - count a pinned node with outgoing links in the last column when sizing outside labels Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/sankey-data.test.ts | 2 ++ .../charts/src/charts/SankeyChart/sankey-data.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 42fdecdf01ec..29d40df06308 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -116,6 +116,8 @@ describe('computeSankeyLayout', () => { ] const links: SankeyLinkInput[] = [{ source: 'x', target: 'y', value: 5 }] expect(() => layoutOf({ nodes, links, nodeAlign: 'left' })).toThrow('needs a whole column') + const unsized = { plotLeft: 0, plotTop: 0, plotWidth: 0, plotHeight: 0 } + expect(() => layoutOf({ nodes, links, nodeAlign: 'left', plot: unsized })).toThrow('needs a whole column') }) it('resolves node colors by label and defaults link color to the source node', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index fc882dea331d..3c843352cfb6 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -202,7 +202,8 @@ export function lastColumnSinkIds( const sinksMoveLast = nodeAlign === 'justify' || nodeAlign === 'right' const ids = new Set() for (const node of nodes) { - if (hasOutgoing.has(node.id)) { + // A pin can hold a node with outgoing links in the last column, and its label sits there too. + if (node.column === undefined && hasOutgoing.has(node.id)) { continue } const column = node.column ?? (sinksMoveLast ? lastColumn : (depths.get(node.id) ?? 1) - 1) @@ -228,10 +229,7 @@ export function computeSankeyLayout({ colorForLabel, resolveColor, }: ComputeSankeyLayoutOptions): SankeyChartLayout { - if (nodes.length === 0 || links.length === 0 || plot.plotWidth <= 0 || plot.plotHeight <= 0) { - return EMPTY_SANKEY_LAYOUT as SankeyChartLayout - } - + // Validate before the empty-layout return, so a bad graph fails before the chart has a size. const nodeIds = new Set() for (const node of nodes) { if (nodeIds.has(node.id)) { @@ -261,6 +259,10 @@ export function computeSankeyLayout({ if (!Number.isFinite(nodePadding) || nodePadding < 0) { throw new Error(`Sankey nodePadding must be a finite number of 0 or more: ${nodePadding}`) } + if (nodes.length === 0 || links.length === 0 || plot.plotWidth <= 0 || plot.plotHeight <= 0) { + return EMPTY_SANKEY_LAYOUT as SankeyChartLayout + } + const columnCount = columnCountOf(nodes, links) const effectiveNodeWidth = Math.min(nodeWidth, plot.plotWidth / Math.max(1, columnCount)) From dac8040a123554ff038f1534535f63564fbda33a Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 14:32:50 +0100 Subject: [PATCH 63/80] fix(quill-charts): keep sankey header room when the top margin is overridden Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/SankeyChart.test.tsx | 17 +++++++++++++++++ .../src/charts/SankeyChart/SankeyChart.tsx | 11 +++++++---- .../charts/src/charts/SankeyChart/types.ts | 4 ++-- .../packages/charts/src/docs/chart-types.md | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 146a3ef81a71..4dfc2b641858 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -322,4 +322,21 @@ describe('SankeyChart', () => { const onlySink = outsideLabelWidth([{ id: 'only', label: sizedBy }], [], nodeAlign, false, String) expect(outsideLabelWidth(nodes, links, nodeAlign, false, String)).toBe(onlySink) }) + + it('keeps room for column headers when a consumer overrides the top margin', () => { + const { chart } = renderHogChart( + + ) + // 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('[data-attr="hog-chart-sankey-node-label"]') + ).find((label) => label.textContent?.startsWith('Start')) + expect(start?.style.top).toBe(`${18 + (400 - 18 - 8) / 2}px`) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index d351f9126e64..f0d3c62b8e38 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -108,10 +108,13 @@ function SankeyChartInner({ ) const margins = useMemo(() => { - const computed = { ...BASE_MARGINS, top: BASE_MARGINS.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0) } - const applied = marginsOverride ? applyMarginOverride(computed, marginsOverride) : computed - // `outside` labels are sized for this room, so an override cannot take it away from them. - return { ...applied, right: applied.right + outsideWidth } + const applied = marginsOverride ? applyMarginOverride(BASE_MARGINS, marginsOverride) : BASE_MARGINS + // Column headers and `outside` labels are sized for their room, so an override cannot take it away. + return { + ...applied, + top: applied.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0), + right: applied.right + outsideWidth, + } }, [hasColumnLabels, outsideWidth, marginsOverride]) const { canvasRef, overlayCanvasRef, wrapperRef, dimensions, ctx, overlayCtx } = useChartCanvas({ margins }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts index f401cc357a44..0de68c3fc491 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/types.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/types.ts @@ -54,8 +54,8 @@ export interface SankeyChartConfig { * anchors it to the hovered node's right edge or the ribbon's midpoint. */ placement?: 'follow-data' | 'top' | 'cursor' } - /** Per-side margin overrides. Should be referentially stable. `outside` labels add their room to - * the right margin on top of an override. */ + /** Per-side margin overrides. Should be referentially stable. Column headers add their room to + * the top margin, and `outside` labels to the right margin, on top of an override. */ margins?: Partial } diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index b351038695ce..22c4cc38da92 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -87,7 +87,7 @@ There is no `series` or `labels`. - `nodeAlign` decides where a flow that ends early sits: `justify` (default) moves terminal nodes to the last column; `left` keeps each node at its own depth, which reads right when columns are stages. `preserveNodeOrder` keeps input order within a column instead of untangling ribbons. A node with `column` set is pinned there whatever its depth, and the graph grows to fit the highest pin; use it when the data names its own stage, so `columnLabels` stay truthful for flows that end early or start late. -- `columnLabels` renders headers above each column and reserves room for them; a header too wide for its column truncates. +- `columnLabels` renders headers above each column and reserves room for them, added to any consumer `margins.top`; a header too wide for its column truncates. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px), so names in that column read in full on a narrow chart. The margin counts only the nodes that land in the last column under `nodeAlign`, and it adds to a consumer `margins.right` rather than being replaced by it. Hovering a label counts as hovering its node, so a truncated name shows in full in the tooltip. From 67334773dde9c8438c7cba5a3df0012f6d70ef94 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 19:09:51 +0100 Subject: [PATCH 64/80] chore(mcp-analytics): snapshot the cluster journey chart in storybook The intent clustering story ends above the journey chart at the default viewport, so visual review never covered it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../frontend/MCPAnalyticsDashboard.stories.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/products/mcp_analytics/frontend/MCPAnalyticsDashboard.stories.tsx b/products/mcp_analytics/frontend/MCPAnalyticsDashboard.stories.tsx index 676dd40b9d28..da027e9dc182 100644 --- a/products/mcp_analytics/frontend/MCPAnalyticsDashboard.stories.tsx +++ b/products/mcp_analytics/frontend/MCPAnalyticsDashboard.stories.tsx @@ -913,3 +913,16 @@ export const IntentClustering: Story = { featureFlags: [FEATURE_FLAGS.MCP_ANALYTICS_INTENT_ROUTING], }, } + +// The journey chart is the last section of a pane sized to the viewport, so the default 720px +// snapshot ends above it. A taller viewport brings it into the snapshot. +export const IntentClusteringJourney: Story = { + parameters: { + pageUrl: urls.mcpAnalyticsIntentClustering(), + featureFlags: [FEATURE_FLAGS.MCP_ANALYTICS_INTENT_ROUTING], + testOptions: { + viewport: { width: 1280, height: 1800 }, + waitForSelector: '[data-attr=mcp-cluster-journey-sankey] [data-attr=hog-chart-sankey-node-label]', + }, + }, +} From ef8f744d7aeb48f480085b6769857c6f5e7e9943 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 21:55:56 +0100 Subject: [PATCH 65/80] fix(quill-charts): keep a sankey plot when outside labels outgrow narrow charts - Take the outside label room from the plot, capped at half of it, so a chart narrower than the labels still draws its nodes and the labels truncate. - Size outside labels from the larger of a node's inflow and outflow, which is the value the layout and the label show. - Count every node as one column for the MCP paths padding when steps are not pinned, because the layout then places nodes by depth. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/SankeyChart.test.tsx | 20 +++++++++++ .../src/charts/SankeyChart/SankeyChart.tsx | 36 ++++++++++++------- .../src/charts/SankeyChart/sankey-labels.ts | 6 +++- .../packages/charts/src/docs/chart-types.md | 2 +- .../ui-apps/components/PathsVisualizer.tsx | 5 +-- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 4dfc2b641858..1e5b957c6d00 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -323,6 +323,26 @@ describe('SankeyChart', () => { 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( + + ) + const labels = Array.from( + chart.element.querySelectorAll('[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( ({ const margins = useMemo(() => { const applied = marginsOverride ? applyMarginOverride(BASE_MARGINS, marginsOverride) : BASE_MARGINS - // Column headers and `outside` labels are sized for their room, so an override cannot take it away. - return { - ...applied, - top: applied.top + (hasColumnLabels ? COLUMN_LABEL_HEIGHT : 0), - right: applied.right + outsideWidth, - } - }, [hasColumnLabels, outsideWidth, marginsOverride]) + // 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] + ) + // Nodes that share a label share a palette slot, so the same tool in two columns keeps one hue. const colorForLabel = useMemo(() => { const slots = new Map() @@ -139,7 +151,7 @@ function SankeyChartInner({ computeSankeyLayout({ nodes, links, - plot: dimensions ?? { plotLeft: 0, plotTop: 0, plotWidth: 0, plotHeight: 0 }, + plot, nodeWidth, nodePadding, nodeAlign, @@ -147,7 +159,7 @@ function SankeyChartInner({ 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 @@ -174,10 +186,10 @@ function SankeyChartInner({ showValues: showNodeValues, valueFormatter, lastColumnLabels, - outsideWidth, + outsideWidth: outsideRoom, }) : [], - [showNodeLabels, layout, showNodeValues, valueFormatter, lastColumnLabels, outsideWidth] + [showNodeLabels, layout, showNodeValues, valueFormatter, lastColumnLabels, outsideRoom] ) const { hoverIndex, hoverPosition, tooltipCtx, handlers } = useSankeyInteraction({ @@ -290,7 +302,7 @@ function SankeyChartInner({ showOverlay={!!dimensions} > {hasColumnLabels ? ( - + ) : null} {showNodeLabels ? : null} {children} diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts index 125dde1c10f7..835b8e7e2162 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-labels.ts @@ -152,8 +152,10 @@ export function outsideLabelWidth( ): number { const lastColumn = lastColumnSinkIds(nodes, links, nodeAlign) const inflow = new Map() + const outflow = new Map() for (const link of links) { inflow.set(link.target, (inflow.get(link.target) ?? 0) + link.value) + outflow.set(link.source, (outflow.get(link.source) ?? 0) + link.value) } let widest = 0 for (const node of nodes) { @@ -161,7 +163,9 @@ export function outsideLabelWidth( continue } const label = node.label ?? node.id - const text = showValues ? `${label} ${valueFormatter(inflow.get(node.id) ?? 0)}` : label + // The layout gives a node the larger of its inflow and outflow, and the label shows that value. + const value = Math.max(inflow.get(node.id) ?? 0, outflow.get(node.id) ?? 0) + const text = showValues ? `${label} ${valueFormatter(value)}` : label widest = Math.max(widest, measureLabelWidth(text, LABEL_FONT)) } return widest === 0 ? 0 : Math.min(MAX_OUTSIDE_LABEL_WIDTH, Math.ceil(widest) + LABEL_GAP) diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 22c4cc38da92..3ff62ce34310 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -89,7 +89,7 @@ There is no `series` or `labels`. A node with `column` set is pinned there whatever its depth, and the graph grows to fit the highest pin; use it when the data names its own stage, so `columnLabels` stay truthful for flows that end early or start late. - `columnLabels` renders headers above each column and reserves room for them, added to any consumer `margins.top`; a header too wide for its column truncates. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. - `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px), so names in that column read in full on a narrow chart. The margin counts only the nodes that land in the last column under `nodeAlign`, and it adds to a consumer `margins.right` rather than being replaced by it. + `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px and at half the plot width), so names in that column read in full on a narrow chart. The margin counts only the nodes that land in the last column under `nodeAlign`, and it adds to a consumer `margins.right` rather than being replaced by it. Hovering a label counts as hovering its node, so a truncated name shows in full in the tooltip. A label that would print over a larger neighbor's label in the same column is dropped; the tooltip still names that node. `showNodeValues` appends the node value. diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 8e675cb9ae2e..c328db801ac9 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -61,12 +61,13 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement const nodePadding = useMemo(() => { const perStep = new Map() for (const node of graph.nodes) { - const step = parsePathNodeKey(node.id).step + // Unpinned, the layout places nodes by depth, so nodes from any step can share one column. + const step = graph.stepsPinned ? parsePathNodeKey(node.id).step : 0 perStep.set(step, (perStep.get(step) ?? 0) + 1) } const densest = Math.max(1, ...perStep.values()) return densest > 1 ? Math.min(NODE_PADDING, MAX_COLUMN_PADDING / (densest - 1)) : NODE_PADDING - }, [graph.nodes]) + }, [graph.nodes, graph.stepsPinned]) const config = useMemo( () => ({ ...CHART_CONFIG, columnLabels, nodePadding }), [columnLabels, nodePadding] From d7788d9cf4ddf3db94767642c181327283eab6e3 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Wed, 7 Oct 2026 21:58:58 +0100 Subject: [PATCH 66/80] fix(quill-charts): report the sankey node area as the plot to chart overlays Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/charts/src/charts/SankeyChart/SankeyChart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 814bbf1d0a1e..07c2e8f38e58 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -266,7 +266,7 @@ function SankeyChartInner({ } return { scales, - dimensions, + dimensions: { ...dimensions, plotWidth: plot.plotWidth }, labels: [], series: [], theme, @@ -275,7 +275,7 @@ function SankeyChartInner({ axis: { orientation: 'vertical', xTickFormatter: undefined, isPercent: false }, yGutters: [], } - }, [scales, dimensions, theme, canvasBounds]) + }, [scales, dimensions, plot.plotWidth, theme, canvasBounds]) const sankeyValue = useMemo>( () => ({ layout, canvasBounds }), [layout, canvasBounds] From 6369f792bc1337892ee075e27adb0ed8390edb74 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Thu, 8 Oct 2026 09:10:47 +0100 Subject: [PATCH 67/80] fix(mcp): pin path steps only when they fit the visualizer's columns Generated-By: PostHog Desktop Task-Id: 24cba9b2-2aaf-4cd1-b3b0-6a2aa318d7ea --- .../paths/pathsChartTransforms.test.ts | 37 +++++++++++-------- .../insights/paths/pathsChartTransforms.ts | 15 +++++--- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts index 3b7fc974671c..b947d4d6fcc4 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.test.ts @@ -10,19 +10,26 @@ describe('pathsChartTransforms', () => { expect(pathStartCount(edges)).toBe(10) }) - it('pins each node to its step, labels URLs by path on a single origin, and keeps event names whole', () => { - const graph = buildPathsSankeyGraph( - [ - { source: '1_https://example.com/home', target: '3_https://example.com/#/app/settings', value: 4 }, - { source: '1_https://example.com/home', target: '2_clicked: signup button', value: 2 }, - ], - { labelUrls: true, pinSteps: true } - ) - expect(graph.nodes.map(({ label, column }) => [label, column])).toEqual([ - ['/home', 0], - ['/#/app/settings', 2], - ['clicked: signup button', 1], - ]) - expect(graph.stepCount).toBe(3) - }) + it.each([ + { pinStepsUpTo: 3, columns: [0, 2, 1], stepsPinned: true }, + { pinStepsUpTo: 2, columns: [undefined, undefined, undefined], stepsPinned: false }, + ])( + 'pins nodes to their steps only within $pinStepsUpTo steps, labels URLs by path on a single origin, and keeps event names whole', + ({ pinStepsUpTo, columns, stepsPinned }) => { + const graph = buildPathsSankeyGraph( + [ + { source: '1_https://example.com/home', target: '3_https://example.com/#/app/settings', value: 4 }, + { source: '1_https://example.com/home', target: '2_clicked: signup button', value: 2 }, + ], + { labelUrls: true, pinStepsUpTo } + ) + expect(graph.nodes.map(({ label }) => label)).toEqual([ + '/home', + '/#/app/settings', + 'clicked: signup button', + ]) + expect(graph.nodes.map(({ column }) => column)).toEqual(columns) + expect(graph).toMatchObject({ stepCount: 3, stepsPinned }) + } + ) }) diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts index 8f8ab3fac46d..2987dd0693e6 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts @@ -85,16 +85,19 @@ export interface PathsSankeyGraph { /** The highest step in the result. */ stepCount: number /** Whether nodes sit in their step's column, so the caller can label columns by step. False - * when pinning was off or the result has more steps than the chart has columns. */ + * when pinning was off or the result has more steps than `pinStepsUpTo` allows. */ stepsPinned: boolean } export interface PathsSankeyGraphOptions { /** Short labels for page URLs. Off for a host that draws its own labels. */ labelUrls?: boolean - /** Pin each node to its step's column, so a path that ends early or an edge whose earlier steps - * were cut still sits under the right step. */ - pinSteps?: boolean + /** Pin each node to its step's column when the result has at most this many steps, so a path + * that ends early or an edge whose earlier steps were cut still sits under the right step. A + * pinned layout keeps a column for every step up to the highest, so set this to the columns + * the caller can show. Past it the layout places nodes by depth, which skips empty steps. + * Omit to never pin. */ + pinStepsUpTo?: number nodeColor?: (key: string) => string | undefined linkColor?: string } @@ -103,13 +106,13 @@ export interface PathsSankeyGraphOptions { * is two nodes. Node `meta` is the name without its step; link `meta` is the result row. */ export function buildPathsSankeyGraph( edges: Edge[], - { labelUrls = false, pinSteps = false, nodeColor, linkColor }: PathsSankeyGraphOptions = {} + { labelUrls = false, pinStepsUpTo, nodeColor, linkColor }: PathsSankeyGraphOptions = {} ): PathsSankeyGraph { const keys = pathNodeKeys(edges) const parsed = keys.map((key) => ({ key, ...parsePathNodeKey(key) })) const singleOrigin = labelUrls && pathOrigins(parsed.map(({ name }) => name)).size <= 1 const stepCount = parsed.reduce((max, { step }) => Math.max(max, step), 0) - const stepsPinned = pinSteps && stepCount - 1 <= MAX_SANKEY_COLUMN + const stepsPinned = pinStepsUpTo !== undefined && stepCount <= Math.min(pinStepsUpTo, MAX_SANKEY_COLUMN + 1) const nodes = parsed.map( ({ key, step, name }): SankeyNodeInput => ({ From aa0abc9a0957bb65a5358c28bbaadfaae75e8635 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Thu, 8 Oct 2026 09:11:00 +0100 Subject: [PATCH 68/80] fix(quill-charts): keep pin types apart in the sankey error reset key Generated-By: PostHog Desktop Task-Id: 24cba9b2-2aaf-4cd1-b3b0-6a2aa318d7ea --- .../charts/src/charts/SankeyChart/SankeyChart.test.tsx | 9 +++++++-- .../charts/src/charts/SankeyChart/SankeyChart.tsx | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 1e5b957c6d00..52d491505bc4 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -223,7 +223,12 @@ describe('SankeyChart', () => { }, { name: 'the node padding', broken: { config: { nodePadding: -1 } } }, { name: 'a NaN pin', broken: { nodes: [{ ...NODES[0], column: NaN }, ...NODES.slice(1)] } }, - ])('recovers from an invalid graph when only $name is corrected', ({ broken }) => { + { + 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( @@ -232,7 +237,7 @@ describe('SankeyChart', () => { expect(onError).toHaveBeenCalled() expect(container.textContent).toContain('Something went wrong') - rerender() + rerender() expect(container.textContent).not.toContain('Something went wrong') }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx index 07c2e8f38e58..b247f082f66b 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.tsx @@ -44,8 +44,9 @@ function graphKey( ): 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, String(node.column)]), + 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], ]) From b6c4efbe0338c37006bbcb8830e1269f332ad2db Mon Sep 17 00:00:00 2001 From: pauldambra Date: Thu, 8 Oct 2026 09:11:06 +0100 Subject: [PATCH 69/80] fix(mcp): hide sankey node values when the paths view is truncated Generated-By: PostHog Desktop Task-Id: 24cba9b2-2aaf-4cd1-b3b0-6a2aa318d7ea --- .../mcp/src/ui-apps/components/PathsVisualizer.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index c328db801ac9..ec7f768daf80 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -35,7 +35,6 @@ const MAX_COLUMN_PADDING = 120 const CHART_CONFIG: SankeyChartConfig = { linkOpacity: 0.35, - showNodeValues: true, valueFormatter: formatNumber, } @@ -52,7 +51,11 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement () => [...allEdges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES), [allEdges] ) - const graph = useMemo(() => buildPathsSankeyGraph(edges, { labelUrls: true, pinSteps: true }), [edges]) + const truncated = edges.length < allEdges.length + const graph = useMemo( + () => buildPathsSankeyGraph(edges, { labelUrls: true, pinStepsUpTo: MAX_SCROLL_COLUMNS }), + [edges] + ) // Step headers only line up when every node sits in its step's column. const columnLabels = useMemo( () => (graph.stepsPinned ? Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) : []), @@ -68,9 +71,10 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement const densest = Math.max(1, ...perStep.values()) return densest > 1 ? Math.min(NODE_PADDING, MAX_COLUMN_PADDING / (densest - 1)) : NODE_PADDING }, [graph.nodes, graph.stepsPinned]) + // A node's value counts only its drawn ribbons, so a truncated view would label partial totals. const config = useMemo( - () => ({ ...CHART_CONFIG, columnLabels, nodePadding }), - [columnLabels, nodePadding] + () => ({ ...CHART_CONFIG, columnLabels, nodePadding, showNodeValues: !truncated }), + [columnLabels, nodePadding, truncated] ) const labelOf = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node.label])), [graph.nodes]) @@ -100,7 +104,6 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement MAX_SCROLL_COLUMNS ) const chartWidth = columnCount > MAX_STEPS_IN_FRAME ? `${(columnCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' - const truncated = edges.length < allEdges.length return (
From 68ccfcf625f2eb5a8a7988e522a81b4584b369ae Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 9 Oct 2026 22:56:29 +0100 Subject: [PATCH 70/80] test(mcp): cover the paths visualizer's truncation, step count and scroll width Moves the view math out of PathsVisualizer into a pure buildPathsView so vitest can assert it without rendering. The unit project now resolves @posthog/quill-charts the way the ui-apps build does, because the shared paths transforms under products/ import it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui-apps/components/PathsVisualizer.tsx | 63 ++------------- .../mcp/src/ui-apps/components/pathsView.ts | 80 +++++++++++++++++++ services/mcp/tests/unit/paths-view.test.ts | 41 ++++++++++ services/mcp/vitest.config.mts | 11 +++ 4 files changed, 137 insertions(+), 58 deletions(-) create mode 100644 services/mcp/src/ui-apps/components/pathsView.ts create mode 100644 services/mcp/tests/unit/paths-view.test.ts diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index ec7f768daf80..38cb55fad34c 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -5,34 +5,16 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@posthog/quill import { SankeyChart } from '@posthog/quill-charts' import type { SankeyChartConfig } from '@posthog/quill-charts' -import { - buildPathsSankeyGraph, - parsePathNodeKey, - pathStartCount, -} from 'products/product_analytics/frontend/insights/paths/pathsChartTransforms' +import { parsePathNodeKey } from 'products/product_analytics/frontend/insights/paths/pathsChartTransforms' import { ChartHeader } from './ChartHeader' import { useMcpChartTheme } from './charts/theme' +import { buildPathsView } from './pathsView' import type { PathsResultItem, PathsVisualizerProps } from './types' import { formatDuration, formatNumber } from './utils' const TITLE = 'Paths' -// The busiest transitions carry the story; past this many the ribbons are too thin to read and -// the layout's iterative relaxation stops being cheap inside an embedded app. -const MAX_EDGES = 60 - -const MAX_STEPS_IN_FRAME = 5 - -// Bounds the scroll width, so a result with a very high step index cannot size the canvas -// to thousands of percent of the frame. -const MAX_SCROLL_COLUMNS = 25 - -const NODE_PADDING = 6 -// Total gap one column may take out of the h-80 chart's plot, under half its height. When a dense -// column's gaps fill the plot, the layout engine draws its nodes and ribbons with no height. -const MAX_COLUMN_PADDING = 120 - const CHART_CONFIG: SankeyChartConfig = { linkOpacity: 0.35, valueFormatter: formatNumber, @@ -45,32 +27,10 @@ function stepRange(edge: PathsResultItem): string { export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement { const theme = useMcpChartTheme() - const allEdges = useMemo(() => (Array.isArray(results) ? results : []), [results]) - // Busiest first, so a truncated view keeps the transitions that matter. - const edges = useMemo( - () => [...allEdges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES), - [allEdges] + const { allEdges, edges, truncated, graph, columnLabels, nodePadding, stepCount, pathStarts, chartWidth } = useMemo( + () => buildPathsView(results), + [results] ) - const truncated = edges.length < allEdges.length - const graph = useMemo( - () => buildPathsSankeyGraph(edges, { labelUrls: true, pinStepsUpTo: MAX_SCROLL_COLUMNS }), - [edges] - ) - // Step headers only line up when every node sits in its step's column. - const columnLabels = useMemo( - () => (graph.stepsPinned ? Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) : []), - [graph.stepsPinned, graph.stepCount] - ) - const nodePadding = useMemo(() => { - const perStep = new Map() - for (const node of graph.nodes) { - // Unpinned, the layout places nodes by depth, so nodes from any step can share one column. - const step = graph.stepsPinned ? parsePathNodeKey(node.id).step : 0 - perStep.set(step, (perStep.get(step) ?? 0) + 1) - } - const densest = Math.max(1, ...perStep.values()) - return densest > 1 ? Math.min(NODE_PADDING, MAX_COLUMN_PADDING / (densest - 1)) : NODE_PADDING - }, [graph.nodes, graph.stepsPinned]) // A node's value counts only its drawn ribbons, so a truncated view would label partial totals. const config = useMemo( () => ({ ...CHART_CONFIG, columnLabels, nodePadding, showNodeValues: !truncated }), @@ -92,19 +52,6 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement ) } - const pathStarts = pathStartCount(allEdges) - // The busiest edges can leave out the last steps, so count steps on the full result. - const stepCount = allEdges.reduce((max, edge) => Math.max(max, parsePathNodeKey(edge.target).step), 0) - // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths - // insight does, so long paths keep readable columns. - // Unpinned, the chart lays nodes out by depth, and every edge moves one step on, so the - // distinct steps bound its columns where the highest step index does not. - const columnCount = Math.min( - graph.stepsPinned ? graph.stepCount : new Set(graph.nodes.map((node) => parsePathNodeKey(node.id).step)).size, - MAX_SCROLL_COLUMNS - ) - const chartWidth = columnCount > MAX_STEPS_IN_FRAME ? `${(columnCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' - return (
diff --git a/services/mcp/src/ui-apps/components/pathsView.ts b/services/mcp/src/ui-apps/components/pathsView.ts new file mode 100644 index 000000000000..dd314eda7820 --- /dev/null +++ b/services/mcp/src/ui-apps/components/pathsView.ts @@ -0,0 +1,80 @@ +import { + buildPathsSankeyGraph, + parsePathNodeKey, + pathStartCount, +} from 'products/product_analytics/frontend/insights/paths/pathsChartTransforms' +import type { PathsSankeyGraph } from 'products/product_analytics/frontend/insights/paths/pathsChartTransforms' + +import type { PathsResultItem } from './types' + +// The busiest transitions carry the story; past this many the ribbons are too thin to read and +// the layout's iterative relaxation stops being cheap inside an embedded app. +export const MAX_EDGES = 60 + +const MAX_STEPS_IN_FRAME = 5 + +// Bounds the scroll width, so a result with a very high step index cannot size the canvas +// to thousands of percent of the frame. +export const MAX_SCROLL_COLUMNS = 25 + +const NODE_PADDING = 6 +// Total gap one column may take out of the h-80 chart's plot, under half its height. When a dense +// column's gaps fill the plot, the layout engine draws its nodes and ribbons with no height. +const MAX_COLUMN_PADDING = 120 + +export interface PathsView { + allEdges: PathsResultItem[] + /** The transitions the chart draws, busiest first. */ + edges: PathsResultItem[] + truncated: boolean + graph: PathsSankeyGraph + columnLabels: string[] + nodePadding: number + /** The highest step in the full result, not only in the drawn transitions. */ + stepCount: number + pathStarts: number + /** CSS width of the chart's scroll content. */ + chartWidth: string +} + +export function buildPathsView(results: unknown): PathsView { + const allEdges: PathsResultItem[] = Array.isArray(results) ? results : [] + // Busiest first, so a truncated view keeps the transitions that matter. + const edges = [...allEdges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES) + const graph = buildPathsSankeyGraph(edges, { labelUrls: true, pinStepsUpTo: MAX_SCROLL_COLUMNS }) + // Step headers only line up when every node sits in its step's column. + const columnLabels = graph.stepsPinned ? Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) : [] + + const perStep = new Map() + for (const node of graph.nodes) { + // Unpinned, the layout places nodes by depth, so nodes from any step can share one column. + const step = graph.stepsPinned ? parsePathNodeKey(node.id).step : 0 + perStep.set(step, (perStep.get(step) ?? 0) + 1) + } + const densest = Math.max(1, ...perStep.values()) + const nodePadding = densest > 1 ? Math.min(NODE_PADDING, MAX_COLUMN_PADDING / (densest - 1)) : NODE_PADDING + + // The busiest edges can leave out the last steps, so count steps on the full result. + const stepCount = allEdges.reduce((max, edge) => Math.max(max, parsePathNodeKey(edge.target).step), 0) + // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths + // insight does, so long paths keep readable columns. + // Unpinned, the chart lays nodes out by depth, and every edge moves one step on, so the + // distinct steps bound its columns where the highest step index does not. + const columnCount = Math.min( + graph.stepsPinned ? graph.stepCount : new Set(graph.nodes.map((node) => parsePathNodeKey(node.id).step)).size, + MAX_SCROLL_COLUMNS + ) + const chartWidth = columnCount > MAX_STEPS_IN_FRAME ? `${(columnCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' + + return { + allEdges, + edges, + truncated: edges.length < allEdges.length, + graph, + columnLabels, + nodePadding, + stepCount, + pathStarts: pathStartCount(allEdges), + chartWidth, + } +} diff --git a/services/mcp/tests/unit/paths-view.test.ts b/services/mcp/tests/unit/paths-view.test.ts new file mode 100644 index 000000000000..468c1f705b04 --- /dev/null +++ b/services/mcp/tests/unit/paths-view.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { buildPathsView, MAX_EDGES } from '../../src/ui-apps/components/pathsView' +import type { PathsResultItem } from '../../src/ui-apps/components/types' + +function edge(step: number, from: string, to: string, value: number): PathsResultItem { + return { source: `${step}_${from}`, target: `${step + 1}_${to}`, value } +} + +describe('buildPathsView', () => { + it('draws only the busiest transitions but counts steps on the full result', () => { + const busy = Array.from({ length: MAX_EDGES }, (_, i) => edge(1, '/home', `/page-${i}`, 100 + i)) + const quietDeepStep = edge(6, '/checkout', '/thanks', 1) + + const view = buildPathsView([quietDeepStep, ...busy]) + + expect(view.truncated).toBe(true) + expect(view.edges).toHaveLength(MAX_EDGES) + expect(view.edges).not.toContainEqual(quietDeepStep) + expect(view.edges[0]?.value).toBe(100 + MAX_EDGES - 1) + expect(view.stepCount).toBe(7) + }) + + it.each([ + { name: 'fits five steps in the frame', steps: 4, chartWidth: '100%', pinned: true }, + { name: 'scrolls past five steps', steps: 9, chartWidth: '180%', pinned: true }, + { name: 'caps the scroll width for a very long path', steps: 40, chartWidth: '500%', pinned: false }, + ])('$name', ({ steps, chartWidth, pinned }) => { + const chain = Array.from({ length: steps - 1 }, (_, i) => edge(i + 1, `/p${i}`, `/p${i + 1}`, 10)) + + const view = buildPathsView(chain) + + expect(view.chartWidth).toBe(chartWidth) + expect(view.graph.stepsPinned).toBe(pinned) + expect(view.columnLabels).toHaveLength(pinned ? steps : 0) + }) + + it('returns an empty view for a non-array result', () => { + expect(buildPathsView(undefined)).toMatchObject({ allEdges: [], edges: [], truncated: false, stepCount: 0 }) + }) +}) diff --git a/services/mcp/vitest.config.mts b/services/mcp/vitest.config.mts index d1bc3cee65e7..1ff5c74cda33 100644 --- a/services/mcp/vitest.config.mts +++ b/services/mcp/vitest.config.mts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' @@ -18,6 +19,16 @@ export default defineConfig({ projects: [ { plugins: [tsconfigPaths({ root: '.' }), textLoader], + resolve: { + alias: [ + // Files under products/ sit outside this package's node_modules tree, so they + // cannot find quill-charts on their own. Mirrors vite.ui-apps.config.ts. + { + find: /^@posthog\/quill-charts$/, + replacement: resolve(__dirname, '../../packages/quill/packages/charts/src/index.ts'), + }, + ], + }, test: { name: 'unit', globals: true, From 1be81e41efe6d6720a799fcde7425e5d58f12f27 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 9 Oct 2026 22:57:38 +0100 Subject: [PATCH 71/80] chore(quill-charts): document the sankey accessor methods Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/quill/packages/charts/src/docs/TESTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/quill/packages/charts/src/docs/TESTING.md b/packages/quill/packages/charts/src/docs/TESTING.md index 8d544337af93..0971b3ada92d 100644 --- a/packages/quill/packages/charts/src/docs/TESTING.md +++ b/packages/quill/packages/charts/src/docs/TESTING.md @@ -52,6 +52,8 @@ chart.anomalyPoints() // [{ element, color }, …] (TimeSeriesLineChart) chart.annotationBadges() // HTMLElement[] chart.legendItems() // [{ label, secondaryLabel }, …] legend rows in the render scope, empty when hidden chart.clickLegendItem('A', { additive: true }) // click a legend row, additive toggles instead of isolating +chart.sankeyNodeLabels() // ['/home', '/pricing', …] Sankey node labels in layout order, after overlap hiding +chart.sankeyColumnLabels() // ['Step 1', 'Step 2', …] Sankey column headers, left to right ``` For interactions, use the module-level helpers: From e93d4e7ffa8569b09c1acbf0cefa5b74571083ac Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 9 Oct 2026 23:29:18 +0100 Subject: [PATCH 72/80] fix(quill-charts): count nodes fed only by zero links as sankey sources A graph like a->b=0, b->c=10 measured a total of 0 and drew nothing. Detect the all-zero case from the link values instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/sankey-data.test.ts | 11 +++++++++++ .../charts/src/charts/SankeyChart/sankey-data.ts | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts index 365b54aa71b4..fa00de8775af 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.test.ts @@ -102,6 +102,17 @@ describe('computeSankeyLayout', () => { expect(layout.nodes).toHaveLength(0) expect(layout.total).toBe(0) }) + + it('measures the total from the nodes fed by no flow when an earlier link is zero', () => { + const layout = layoutOf({ + links: [ + { source: 'start', target: 'a', value: 0 }, + { source: 'a', target: 'done', value: 10 }, + ], + }) + expect(layout.nodes.length).toBeGreaterThan(0) + expect(layout.total).toBe(10) + }) }) describe('sankeyHitAt', () => { diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index ff554f0b579c..38145503bfb1 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -264,10 +264,13 @@ export function computeSankeyLayout({ const columnStep = columnCount <= 1 ? 0 : (plot.plotWidth - effectiveNodeWidth) / (columnCount - 1) const columnX = Array.from({ length: columnCount }, (_, column) => plot.plotLeft + column * columnStep) - const total = graph.nodes.filter((node) => node.targetLinks.length === 0).reduce((sum, node) => sum + node.value, 0) + // Zero-value links do not feed a node, so a node fed only by them still counts as a source. + const total = graph.nodes + .filter((node) => node.targetLinks.every((link) => link.value === 0)) + .reduce((sum, node) => sum + node.value, 0) - // Guard against all-zero flow: the layout engine produces NaN coordinates when total is 0 - if (total === 0) { + // The layout engine produces NaN coordinates when there is no flow at all. + if (links.every((link) => link.value === 0)) { return EMPTY_SANKEY_LAYOUT as SankeyChartLayout } From 0e623e39f61736eeb97f5e3ce8e5f81aa1f6c2a6 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 9 Oct 2026 23:41:53 +0100 Subject: [PATCH 73/80] fix(quill-charts): keep sankey emphasis linear and alpha-safe Dim ribbons linearly with hover progress instead of applying progress twice. Account for a ribbon color's own alpha when repainting emphasized ribbons. Leave translucent emphasized nodes at their resting paint so alpha does not compound. Drop the cursor position from hover state when tooltips are off so mouse moves inside one item skip re-renders. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/SankeyChart/draw-sankey.test.ts | 31 +++++++++++++++++++ .../src/charts/SankeyChart/draw-sankey.ts | 22 ++++++++----- .../SankeyChart/useSankeyInteraction.ts | 4 ++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts index e89c2c3bca7a..27caa2f32098 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.test.ts @@ -123,4 +123,35 @@ describe('drawSankeyHover', () => { const composite = 1 - painted.reduce((clear, alpha) => clear * (1 - alpha), 1) expect(composite).toBeCloseTo(expectedOpacity) }) + + it('composites an emphasized ribbon with a translucent color to the target opacity', () => { + const layout = layoutOf(0) + layout.links[0].color = 'rgba(255, 0, 0, 0.5)' + const emphasis = emphasisForHit(layout, { kind: 'link', index: 0 }) + const { ctx, strokeAlphas } = recordingCtx({ [BACKGROUND]: '#202023' }) + drawSankey(ctx, layout, { linkOpacity: 0.4, emphasis, backgroundColor: BACKGROUND }) + + const painted = strokeAlphas.map((alpha) => alpha * 0.5) + const composite = 1 - painted.reduce((clear, alpha) => clear * (1 - alpha), 1) + expect(composite).toBeCloseTo(0.5 * 0.85) + }) + + it('dims an inactive ribbon in proportion to hover progress', () => { + const layout = layoutOf() + const { ctx, strokes, strokeAlphas } = recordingCtx({ [BACKGROUND]: '#202023' }) + drawSankeyHover(ctx, layout, emphasisForHit(layout, { kind: 'link', index: 0 }), { + linkOpacity: 0.4, + backgroundColor: BACKGROUND, + progress: 0.5, + }) + + const fullDim = recordingCtx({ [BACKGROUND]: '#202023' }) + drawSankeyHover(fullDim.ctx, layout, emphasisForHit(layout, { kind: 'link', index: 0 }), { + linkOpacity: 0.4, + backgroundColor: BACKGROUND, + progress: 1, + }) + expect(strokeAlphas[0]).toBeCloseTo(0.5) + expect(strokes[0]).toBe(fullDim.strokes[0]) + }) }) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts index 27bf3c7715b0..c79a8bc43556 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/draw-sankey.ts @@ -143,19 +143,23 @@ function paintEmphasis( const linkColor = parseableColor(ctx, link.color) if (linkColor) { const resting = mixColors(dimTarget, linkColor, options.linkOpacity) - // Fading the opaque dim in with progress keeps the first frame identical to the resting layer. - strokeLink(ctx, link, mixColors(resting, dimTarget, dim), options.progress) + // The dim is painted at `progress` alpha over the resting ribbon, so the color carries the full + // dim: the two together move the ribbon toward the target linearly, and the first frame + // matches the resting layer. + strokeLink(ctx, link, mixColors(resting, dimTarget, HOVER_DIM_AMOUNT), options.progress) } } // A resting opacity above the hover target must not drop when the flow is emphasized. const targetOpacity = Math.max(HOVER_LINK_OPACITY, options.linkOpacity) const activeOpacity = options.linkOpacity + (targetOpacity - options.linkOpacity) * options.progress // The emphasized ribbon is already on the canvas at `linkOpacity`, and canvas alpha compounds. - // This repaint opacity makes the composite equal `activeOpacity` instead of overshooting it. - const repaintOpacity = - options.linkOpacity < 1 ? (activeOpacity - options.linkOpacity) / (1 - options.linkOpacity) : 0 - if (repaintOpacity > 0) { - for (const link of emphasis.links) { + // The repaint opacity makes the composite equal `activeOpacity` instead of overshooting it. A + // color's own alpha scales both the resting and the repaint stroke, so it enters the solution. + for (const link of emphasis.links) { + const colorAlpha = d3Color(link.color)?.opacity ?? 1 + const restingAlpha = colorAlpha * options.linkOpacity + const repaintOpacity = restingAlpha < 1 ? (activeOpacity - options.linkOpacity) / (1 - restingAlpha) : 0 + if (repaintOpacity > 0) { strokeLink(ctx, link, link.color, repaintOpacity) } } @@ -170,6 +174,10 @@ function paintEmphasis( } } for (const node of emphasis.nodes) { + // A translucent node repainted over itself compounds its alpha, so it keeps its resting paint. + if ((d3Color(node.color)?.opacity ?? 1) < 1) { + continue + } const highlightBase = node === emphasis.focus ? parseableColor(ctx, node.color) : null const color = highlightBase ? mixColors(highlightBase, HOVER_HIGHLIGHT_TARGET, HOVER_HIGHLIGHT_AMOUNT * options.progress) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts index 0a19cf6ed785..e623b8320145 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/useSankeyInteraction.ts @@ -153,7 +153,9 @@ export function useSankeyInteraction({ const showHit = useCallback( (hit: SankeyHit, cursor: { x: number; y: number }) => { - setHover(hitToHoverIndex(layoutRef.current, hit), cursor) + // Only the tooltip reads the cursor position. Without one, a constant position lets React skip + // the state update on every mouse move inside the same node or ribbon. + setHover(hitToHoverIndex(layoutRef.current, hit), showTooltip ? cursor : null) if (showTooltip) { const canvasBounds = canvasRef.current?.getBoundingClientRect() ?? new DOMRect() setTooltipCtx(buildTooltipCtx(layoutRef.current, hit, cursor, canvasBounds)) From 6086604c96ab1442d2f4506386dac7a8b8ea0fc9 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 9 Oct 2026 23:48:45 +0100 Subject: [PATCH 74/80] fix(paths): report errors caught by the sankey chart boundary Co-Authored-By: Claude Opus 5.5 (1M context) --- .../product_analytics/frontend/insights/paths/PathsChart.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/products/product_analytics/frontend/insights/paths/PathsChart.tsx b/products/product_analytics/frontend/insights/paths/PathsChart.tsx index 89869542d213..310c14e18e69 100644 --- a/products/product_analytics/frontend/insights/paths/PathsChart.tsx +++ b/products/product_analytics/frontend/insights/paths/PathsChart.tsx @@ -15,6 +15,8 @@ import { insightLogic } from 'scenes/insights/insightLogic' import { PathsLink } from '~/queries/schema/schema-general' import { shouldQueryBeAsync } from '~/queries/utils' +import { makeChartErrorHandler } from 'products/product_analytics/frontend/insights/trends/shared/chartErrorHandler' + import { FALLBACK_CANVAS_WIDTH } from './constants' import { PathNodeCards } from './PathNodeCards' import { buildPathsGraph, maxPathLayer, pathsChartWidth } from './pathsChartData' @@ -27,6 +29,7 @@ import { pathsInteractionLogic } from './pathsInteractionLogic' const DEFAULT_PATHS_ID = 'default_paths' const FALLBACK_COLOR = '#000000' const NO_MARGINS = { top: 0, right: 0, bottom: 0, left: 0 } +const handleChartError = makeChartErrorHandler('paths-sankey-chart') // The cards carry every label and number, so the chart draws bare nodes and ribbons. Node order // follows the result, as the SVG renderer did, so the two renderers agree on where a step sits. @@ -143,6 +146,7 @@ export function PathsChart(): JSX.Element { config={CHART_CONFIG} highlight={highlight} onHoverChange={onHoverChange} + onError={handleChartError} className="h-full" dataAttr="paths-sankey-chart" > From e65cfa96c71b53df47cad91b49efd2674c1c024f Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 10 Oct 2026 00:02:31 +0100 Subject: [PATCH 75/80] fix(quill-charts): keep the sankey hover when the theme rereads an equal palette useChartTheme returns a new colors array on every class change on or . SankeyChart keyed its palette on that array, so the layout rebuilt and the hover and tooltip cleared. The snapshot runner sets the theme before each capture, so the HoveredLink story never showed a hover. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/charts/SankeyChart/SankeyChart.test.tsx | 10 ++++++++++ .../charts/src/charts/SankeyChart/SankeyChart.tsx | 12 +++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx index 3a4207faa6fc..12dac8f59a6b 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx +++ b/packages/quill/packages/charts/src/charts/SankeyChart/SankeyChart.test.tsx @@ -65,6 +65,16 @@ describe('SankeyChart', () => { fireEvent.click(chart.element) expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', value: 12 })) + rerender( + + ) + expect(getHogChartTooltip()?.textContent).toContain('Tool A') + onNodeClick.mockClear() rerender( ({ const { canvasRef, overlayCanvasRef, wrapperRef, dimensions, ctx, overlayCtx } = useChartCanvas({ margins }) + // `useChartTheme` rereads the CSS variables on any class change on or , 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. + const paletteKey = theme.colors.join('\n') + // Nodes that share a label share a palette slot, so the same tool in two columns keeps one hue. const colorForLabel = useMemo(() => { + const palette = paletteKey.split('\n') const slots = new Map() for (const node of nodes) { if (node.color) { @@ -111,11 +117,11 @@ function SankeyChartInner({ } const label = node.label ?? node.id if (!slots.has(label)) { - slots.set(label, theme.colors[slots.size % theme.colors.length] || FALLBACK_NODE_COLOR) + slots.set(label, palette[slots.size % palette.length] || FALLBACK_NODE_COLOR) } } - return (label: string): string => slots.get(label) ?? (theme.colors[0] || FALLBACK_NODE_COLOR) - }, [nodes, theme.colors]) + return (label: string): string => slots.get(label) ?? (palette[0] || FALLBACK_NODE_COLOR) + }, [nodes, paletteKey]) const layout = useMemo>( () => From 1787dc1fb93816c0a06bd6250ce2d61721573547 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 10 Oct 2026 11:03:40 +0100 Subject: [PATCH 76/80] fix(mcp): keep long paths under their step headers and qualify path starts A result past 25 steps left Sankey nodes unpinned, so a late transition could sit in an early column with no step header. The visualizer now draws steps 1 to 25 and says so. The path-start count only covers the transitions the query returned, which the summary now states. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui-apps/components/PathsVisualizer.tsx | 21 +++++++++---- .../mcp/src/ui-apps/components/pathsView.ts | 30 +++++++++---------- services/mcp/tests/unit/paths-view.test.ts | 20 +++++++++---- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx index 38cb55fad34c..21a7a3716648 100644 --- a/services/mcp/src/ui-apps/components/PathsVisualizer.tsx +++ b/services/mcp/src/ui-apps/components/PathsVisualizer.tsx @@ -9,7 +9,7 @@ import { parsePathNodeKey } from 'products/product_analytics/frontend/insights/p import { ChartHeader } from './ChartHeader' import { useMcpChartTheme } from './charts/theme' -import { buildPathsView } from './pathsView' +import { buildPathsView, MAX_DRAWN_STEPS } from './pathsView' import type { PathsResultItem, PathsVisualizerProps } from './types' import { formatDuration, formatNumber } from './utils' @@ -27,10 +27,18 @@ function stepRange(edge: PathsResultItem): string { export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement { const theme = useMcpChartTheme() - const { allEdges, edges, truncated, graph, columnLabels, nodePadding, stepCount, pathStarts, chartWidth } = useMemo( - () => buildPathsView(results), - [results] - ) + const { + allEdges, + edges, + truncated, + stepsClipped, + graph, + columnLabels, + nodePadding, + stepCount, + pathStarts, + chartWidth, + } = useMemo(() => buildPathsView(results), [results]) // A node's value counts only its drawn ribbons, so a truncated view would label partial totals. const config = useMemo( () => ({ ...CHART_CONFIG, columnLabels, nodePadding, showNodeValues: !truncated }), @@ -96,9 +104,10 @@ export function PathsVisualizer({ results }: PathsVisualizerProps): ReactElement <> {' '} · {formatNumber(pathStarts)} path - {pathStarts === 1 ? ' begins' : 's begin'} at step 1 + {pathStarts === 1 ? ' in this result begins' : 's in this result begin'} at step 1 )} + {stepsClipped && <> · The chart shows steps 1 to {MAX_DRAWN_STEPS}}
) diff --git a/services/mcp/src/ui-apps/components/pathsView.ts b/services/mcp/src/ui-apps/components/pathsView.ts index dd314eda7820..6351e9a5544b 100644 --- a/services/mcp/src/ui-apps/components/pathsView.ts +++ b/services/mcp/src/ui-apps/components/pathsView.ts @@ -13,9 +13,10 @@ export const MAX_EDGES = 60 const MAX_STEPS_IN_FRAME = 5 -// Bounds the scroll width, so a result with a very high step index cannot size the canvas -// to thousands of percent of the frame. -export const MAX_SCROLL_COLUMNS = 25 +// The chart draws transitions up to this step only. That bounds the scroll width, and it keeps +// every node pinned under its step header: unpinned, the layout places nodes by graph depth, so a +// late transition whose earlier steps are missing would sit in an early column. +export const MAX_DRAWN_STEPS = 25 const NODE_PADDING = 6 // Total gap one column may take out of the h-80 chart's plot, under half its height. When a dense @@ -27,6 +28,8 @@ export interface PathsView { /** The transitions the chart draws, busiest first. */ edges: PathsResultItem[] truncated: boolean + /** True when the result has transitions past `MAX_DRAWN_STEPS` that the chart leaves out. */ + stepsClipped: boolean graph: PathsSankeyGraph columnLabels: string[] nodePadding: number @@ -39,16 +42,15 @@ export interface PathsView { export function buildPathsView(results: unknown): PathsView { const allEdges: PathsResultItem[] = Array.isArray(results) ? results : [] + const drawable = allEdges.filter((edge) => parsePathNodeKey(edge.target).step <= MAX_DRAWN_STEPS) // Busiest first, so a truncated view keeps the transitions that matter. - const edges = [...allEdges].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES) - const graph = buildPathsSankeyGraph(edges, { labelUrls: true, pinStepsUpTo: MAX_SCROLL_COLUMNS }) - // Step headers only line up when every node sits in its step's column. - const columnLabels = graph.stepsPinned ? Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) : [] + const edges = [...drawable].sort((a, b) => (b.value ?? 0) - (a.value ?? 0)).slice(0, MAX_EDGES) + const graph = buildPathsSankeyGraph(edges, { labelUrls: true, pinStepsUpTo: MAX_DRAWN_STEPS }) + const columnLabels = Array.from({ length: graph.stepCount }, (_, i) => `Step ${i + 1}`) const perStep = new Map() for (const node of graph.nodes) { - // Unpinned, the layout places nodes by depth, so nodes from any step can share one column. - const step = graph.stepsPinned ? parsePathNodeKey(node.id).step : 0 + const step = parsePathNodeKey(node.id).step perStep.set(step, (perStep.get(step) ?? 0) + 1) } const densest = Math.max(1, ...perStep.values()) @@ -58,18 +60,14 @@ export function buildPathsView(results: unknown): PathsView { const stepCount = allEdges.reduce((max, edge) => Math.max(max, parsePathNodeKey(edge.target).step), 0) // Past five steps the chart grows a fifth of the frame per step and scrolls, as the paths // insight does, so long paths keep readable columns. - // Unpinned, the chart lays nodes out by depth, and every edge moves one step on, so the - // distinct steps bound its columns where the highest step index does not. - const columnCount = Math.min( - graph.stepsPinned ? graph.stepCount : new Set(graph.nodes.map((node) => parsePathNodeKey(node.id).step)).size, - MAX_SCROLL_COLUMNS - ) - const chartWidth = columnCount > MAX_STEPS_IN_FRAME ? `${(columnCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' + const chartWidth = + graph.stepCount > MAX_STEPS_IN_FRAME ? `${(graph.stepCount / MAX_STEPS_IN_FRAME) * 100}%` : '100%' return { allEdges, edges, truncated: edges.length < allEdges.length, + stepsClipped: drawable.length < allEdges.length, graph, columnLabels, nodePadding, diff --git a/services/mcp/tests/unit/paths-view.test.ts b/services/mcp/tests/unit/paths-view.test.ts index 468c1f705b04..52cd3200a7c7 100644 --- a/services/mcp/tests/unit/paths-view.test.ts +++ b/services/mcp/tests/unit/paths-view.test.ts @@ -22,17 +22,25 @@ describe('buildPathsView', () => { }) it.each([ - { name: 'fits five steps in the frame', steps: 4, chartWidth: '100%', pinned: true }, - { name: 'scrolls past five steps', steps: 9, chartWidth: '180%', pinned: true }, - { name: 'caps the scroll width for a very long path', steps: 40, chartWidth: '500%', pinned: false }, - ])('$name', ({ steps, chartWidth, pinned }) => { + { name: 'fits five steps in the frame', steps: 4, chartWidth: '100%', columns: 4, stepsClipped: false }, + { name: 'scrolls past five steps', steps: 9, chartWidth: '180%', columns: 9, stepsClipped: false }, + { + name: 'draws only the first steps of a very long path, each under its step header', + steps: 40, + chartWidth: '500%', + columns: 25, + stepsClipped: true, + }, + ])('$name', ({ steps, chartWidth, columns, stepsClipped }) => { const chain = Array.from({ length: steps - 1 }, (_, i) => edge(i + 1, `/p${i}`, `/p${i + 1}`, 10)) const view = buildPathsView(chain) expect(view.chartWidth).toBe(chartWidth) - expect(view.graph.stepsPinned).toBe(pinned) - expect(view.columnLabels).toHaveLength(pinned ? steps : 0) + expect(view.graph.stepsPinned).toBe(true) + expect(view.columnLabels).toHaveLength(columns) + expect(view.stepsClipped).toBe(stepsClipped) + expect(view.stepCount).toBe(steps) }) it('returns an empty view for a non-array result', () => { From 13263585b4341da4ac0bfe40869a063c1233c9d4 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:11:36 +0000 Subject: [PATCH 77/80] chore(visual): update storybook baselines 1 updated Run: fdf9e0c3-c304-4e39-89b7-8c54502267da Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- frontend/snapshots.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 72ee1c8202a9..dbf4d1645331 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -207,7 +207,7 @@ snapshots: charts-sankeychart--hovered-link--dark: hash: v1.k794b7964.e50f64b0a2556e240047944407ec800fb78ffc2ad3eca93219f235d64883327d.X8189N1MHu1yKO4xcQgIglRR8DdqCLFhNfcqHuspBNE charts-sankeychart--hovered-link--light: - hash: v1.k794b7964.3d80ddb75942d750e8966a762b6605b9780e848478da52fbfc853336f1e1602f._wg3_fRbuh-eGfRO8WO8V6touEXs3l1g1ReCIzOYp7o + hash: v1.k794b7964.e6a0d26ab605825cb5536b0d2ff19e606d1720fe7db242a0b72e710246fa01ad.iXgOgNfOVXu_y_Wg2MG9-4ugoULs_kgICgXVoRmaMh0 charts-sankeychart--left-aligned-with-values--dark: hash: v1.k794b7964.1feb41fa4c6017593646eff1e33c11eb8ef9e224e5776a2bca43c97ff2390f9b.OQ83mEQIk1BTINe94sYI5ByoXnLHMvRo8W_9an60lrI charts-sankeychart--left-aligned-with-values--light: @@ -11948,14 +11948,14 @@ snapshots: hash: v1.k794b7964.78c398feb22adb44344fad6c155ad42ecdbd9d77d80e4e0a60227b4a32abf14f.wmWI9S-nFvoZ5tNpxol33U3eINboLyZM9cggyHqv8hU scenes-app-insights-error-empty-states--validation-error--light: hash: v1.k794b7964.2b012576da17dc343cba68c671217dcfb23f907543fd897d65e42e8793f31958.gL9lf9tk2rIHPSZBxNHmPitiTqUvCG08VR3K9hK7xMo - scenes-app-insights-flag-calls-retention-notice--default--dark: - hash: v1.k794b7964.d9aefe5f9ce9d7a864d6bf0338965adf5c01284b0fa767b3db193cbf9102838b.F_x981isYjYR1oKf0BPU3Agkr_bpR7TqheKovVn432o - scenes-app-insights-flag-calls-retention-notice--default--light: - hash: v1.k794b7964.7923e9cd78a4bd2e62c7f95ce8eb80df0bb9fab29de527b307048b8e5b5011ff.Mrf6YkRMfO82M2uM5Fc4LKBf3vjTEjIDdw4MZ1cPIrA scenes-app-insights-flag-called-rebuild-banner--insight-on-flag-calls--dark: hash: v1.k794b7964.3d946f71319ec412299b8888e9a3fed48a7098bcf0e92cda25f6f34a43071a3f.PbH5UAwbwGY8tveYtVSlUz6TeywtNh7otO1PSPZi9vw scenes-app-insights-flag-called-rebuild-banner--insight-on-flag-calls--light: hash: v1.k794b7964.c1bca29126f3f201df46989d83af5e777ad42f21aba60febca75e2ded1a00d6e.WHUf4o3J7Uo7jwnFaGNXQgqa9jMdohuIOo1eSFbW9a4 + scenes-app-insights-flag-calls-retention-notice--default--dark: + hash: v1.k794b7964.d9aefe5f9ce9d7a864d6bf0338965adf5c01284b0fa767b3db193cbf9102838b.F_x981isYjYR1oKf0BPU3Agkr_bpR7TqheKovVn432o + scenes-app-insights-flag-calls-retention-notice--default--light: + hash: v1.k794b7964.7923e9cd78a4bd2e62c7f95ce8eb80df0bb9fab29de527b307048b8e5b5011ff.Mrf6YkRMfO82M2uM5Fc4LKBf3vjTEjIDdw4MZ1cPIrA scenes-app-insights-insight-quick-start--default--dark: hash: v1.k794b7964.f190c463ee799e93ea5d408200712776460a5a9a920977e2b7a85348350fa43b.dcN7n63RW9QEXstFsPgnAaKodbV6piSN7PMhBuHkaGc scenes-app-insights-insight-quick-start--default--light: From 6071cb0955c84962accc426f659152a1e23aafa2 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:14:23 +0000 Subject: [PATCH 78/80] chore(visual): update storybook baselines 1 updated Run: 0524421d-627a-4290-86e1-aefabd1daff2 Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- frontend/snapshots.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index c2335c9a28c7..ac69669e4ee1 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -211,7 +211,7 @@ snapshots: charts-sankeychart--hovered-link--dark: hash: v1.k794b7964.e50f64b0a2556e240047944407ec800fb78ffc2ad3eca93219f235d64883327d.X8189N1MHu1yKO4xcQgIglRR8DdqCLFhNfcqHuspBNE charts-sankeychart--hovered-link--light: - hash: v1.k794b7964.3d80ddb75942d750e8966a762b6605b9780e848478da52fbfc853336f1e1602f._wg3_fRbuh-eGfRO8WO8V6touEXs3l1g1ReCIzOYp7o + 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: @@ -11956,14 +11956,14 @@ snapshots: hash: v1.k794b7964.78c398feb22adb44344fad6c155ad42ecdbd9d77d80e4e0a60227b4a32abf14f.wmWI9S-nFvoZ5tNpxol33U3eINboLyZM9cggyHqv8hU scenes-app-insights-error-empty-states--validation-error--light: hash: v1.k794b7964.2b012576da17dc343cba68c671217dcfb23f907543fd897d65e42e8793f31958.gL9lf9tk2rIHPSZBxNHmPitiTqUvCG08VR3K9hK7xMo - scenes-app-insights-flag-calls-retention-notice--default--dark: - hash: v1.k794b7964.d9aefe5f9ce9d7a864d6bf0338965adf5c01284b0fa767b3db193cbf9102838b.F_x981isYjYR1oKf0BPU3Agkr_bpR7TqheKovVn432o - scenes-app-insights-flag-calls-retention-notice--default--light: - hash: v1.k794b7964.7923e9cd78a4bd2e62c7f95ce8eb80df0bb9fab29de527b307048b8e5b5011ff.Mrf6YkRMfO82M2uM5Fc4LKBf3vjTEjIDdw4MZ1cPIrA scenes-app-insights-flag-called-rebuild-banner--insight-on-flag-calls--dark: hash: v1.k794b7964.3d946f71319ec412299b8888e9a3fed48a7098bcf0e92cda25f6f34a43071a3f.PbH5UAwbwGY8tveYtVSlUz6TeywtNh7otO1PSPZi9vw scenes-app-insights-flag-called-rebuild-banner--insight-on-flag-calls--light: hash: v1.k794b7964.c1bca29126f3f201df46989d83af5e777ad42f21aba60febca75e2ded1a00d6e.WHUf4o3J7Uo7jwnFaGNXQgqa9jMdohuIOo1eSFbW9a4 + scenes-app-insights-flag-calls-retention-notice--default--dark: + hash: v1.k794b7964.d9aefe5f9ce9d7a864d6bf0338965adf5c01284b0fa767b3db193cbf9102838b.F_x981isYjYR1oKf0BPU3Agkr_bpR7TqheKovVn432o + scenes-app-insights-flag-calls-retention-notice--default--light: + hash: v1.k794b7964.7923e9cd78a4bd2e62c7f95ce8eb80df0bb9fab29de527b307048b8e5b5011ff.Mrf6YkRMfO82M2uM5Fc4LKBf3vjTEjIDdw4MZ1cPIrA scenes-app-insights-insight-quick-start--default--dark: hash: v1.k794b7964.f190c463ee799e93ea5d408200712776460a5a9a920977e2b7a85348350fa43b.dcN7n63RW9QEXstFsPgnAaKodbV6piSN7PMhBuHkaGc scenes-app-insights-insight-quick-start--default--light: From 32c978155b31dc24754545341ddc9f2e6de3cb02 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 10 Oct 2026 11:53:33 +0100 Subject: [PATCH 79/80] chore(mcp): move sankey pinning rules into the docs and drop the vitest alias The pinning rules lived in long doc comments on three types. They now sit in chart-types.md, and the code comments point there. The MCP unit tests install products/* in CI, so the quill-charts alias was not needed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../charts/src/charts/SankeyChart/sankey-data.ts | 7 +------ .../charts/src/charts/SankeyChart/sankey-layout.ts | 7 ++----- .../quill/packages/charts/src/docs/chart-types.md | 3 +++ .../frontend/insights/paths/pathsChartTransforms.ts | 6 +----- services/mcp/src/ui-apps/components/pathsView.ts | 4 +--- services/mcp/vitest.config.mts | 11 ----------- 6 files changed, 8 insertions(+), 30 deletions(-) diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts index 768715281e6b..a381d5822d1a 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-data.ts @@ -13,12 +13,7 @@ export interface SankeyNodeInput { color?: string /** Consumer data handed back through tooltips and click handlers. */ meta?: Meta - /** Pin the node to this zero-based column. Use it when the data carries its own stage (the - * step in a paths result), so a flow that ends early or starts late still sits under the right - * `columnLabels` header. Nodes without a pin follow `nodeAlign` and ignore their neighbours' pins, so - * pin every node or none. Pins must be monotonic with - * the graph's edges — a link whose target column is at or before its source's draws backwards. - * A pin that is not a whole number from 0 to `MAX_SANKEY_COLUMN` throws. */ + /** Pin the node to this zero-based column. See `column` in docs/chart-types.md for the rules. */ column?: number } diff --git a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts index 05e819370997..a041e185775c 100644 --- a/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts +++ b/packages/quill/packages/charts/src/charts/SankeyChart/sankey-layout.ts @@ -70,11 +70,8 @@ export interface SankeyLayout, n: number) => number nodeAlign(nodeAlign: (node: SankeyNode, n: number) => number): this - /** Pins a node to a column regardless of its depth in the graph; return `undefined` to fall - * back to `nodeAlign` for that node. The column count grows to fit the highest pin, and a fractional - * pin rounds down. The caller owns monotonicity: a pin at or before a source feeding it draws that - * link backwards, with no error. An unpinned node is placed by depth and ignores its neighbours' - * pins, so pin every node or none. */ + /** Pins a node to a column whatever its depth; `undefined` falls back to `nodeAlign`. A fractional + * pin rounds down. */ nodeColumn(): ((node: SankeyNode) => number | undefined) | null nodeColumn(nodeColumn: ((node: SankeyNode) => number | undefined) | null): this diff --git a/packages/quill/packages/charts/src/docs/chart-types.md b/packages/quill/packages/charts/src/docs/chart-types.md index 0990c8e7ab43..d9f0528c7d5e 100644 --- a/packages/quill/packages/charts/src/docs/chart-types.md +++ b/packages/quill/packages/charts/src/docs/chart-types.md @@ -100,6 +100,9 @@ There is no `series` or `labels`. - `nodeAlign` decides where a flow that ends early sits: `justify` (default) moves terminal nodes to the last column; `left` keeps each node at its own depth, which reads right when columns are stages. `preserveNodeOrder` keeps input order within a column instead of untangling ribbons. A node with `column` set is pinned there whatever its depth, and the graph grows to fit the highest pin; use it when the data names its own stage, so `columnLabels` stay truthful for flows that end early or start late. + Pin every node or none, because an unpinned node is placed by depth and ignores its neighbors' pins. + Pins must run forward along the links: a link whose target column is at or before its source's draws backwards, with no error. + A pin that is not a whole number from 0 to `MAX_SANKEY_COLUMN` throws. - `columnLabels` renders headers above each column and reserves room for them, added to any consumer `margins.top`; a header too wide for its column truncates. Node labels are DOM overlays beside each node, truncated to the free space before the next column; the last column's labels sit to its left. `lastColumnLabels: 'outside'` moves the last column's labels to the right of the nodes and reserves a margin for them (capped at 160px and at half the plot width), so names in that column read in full on a narrow chart. The margin counts only the nodes that land in the last column under `nodeAlign`, and it adds to a consumer `margins.right` rather than being replaced by it. diff --git a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts index 2987dd0693e6..1130d0a46a25 100644 --- a/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts +++ b/products/product_analytics/frontend/insights/paths/pathsChartTransforms.ts @@ -92,11 +92,7 @@ export interface PathsSankeyGraph { export interface PathsSankeyGraphOptions { /** Short labels for page URLs. Off for a host that draws its own labels. */ labelUrls?: boolean - /** Pin each node to its step's column when the result has at most this many steps, so a path - * that ends early or an edge whose earlier steps were cut still sits under the right step. A - * pinned layout keeps a column for every step up to the highest, so set this to the columns - * the caller can show. Past it the layout places nodes by depth, which skips empty steps. - * Omit to never pin. */ + /** Pin each node to its step's column when the result has at most this many steps. Omit to never pin. */ pinStepsUpTo?: number nodeColor?: (key: string) => string | undefined linkColor?: string diff --git a/services/mcp/src/ui-apps/components/pathsView.ts b/services/mcp/src/ui-apps/components/pathsView.ts index 6351e9a5544b..6f16e44bccde 100644 --- a/services/mcp/src/ui-apps/components/pathsView.ts +++ b/services/mcp/src/ui-apps/components/pathsView.ts @@ -13,9 +13,7 @@ export const MAX_EDGES = 60 const MAX_STEPS_IN_FRAME = 5 -// The chart draws transitions up to this step only. That bounds the scroll width, and it keeps -// every node pinned under its step header: unpinned, the layout places nodes by graph depth, so a -// late transition whose earlier steps are missing would sit in an early column. +// The chart draws transitions up to this step only, which bounds the scroll width and keeps every node pinned. export const MAX_DRAWN_STEPS = 25 const NODE_PADDING = 6 diff --git a/services/mcp/vitest.config.mts b/services/mcp/vitest.config.mts index 1ff5c74cda33..d1bc3cee65e7 100644 --- a/services/mcp/vitest.config.mts +++ b/services/mcp/vitest.config.mts @@ -1,4 +1,3 @@ -import { resolve } from 'node:path' import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' @@ -19,16 +18,6 @@ export default defineConfig({ projects: [ { plugins: [tsconfigPaths({ root: '.' }), textLoader], - resolve: { - alias: [ - // Files under products/ sit outside this package's node_modules tree, so they - // cannot find quill-charts on their own. Mirrors vite.ui-apps.config.ts. - { - find: /^@posthog\/quill-charts$/, - replacement: resolve(__dirname, '../../packages/quill/packages/charts/src/index.ts'), - }, - ], - }, test: { name: 'unit', globals: true, From 93450e8a2196e43e21da6d2140454492d5955bab Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:53:50 +0000 Subject: [PATCH 80/80] chore(visual): update storybook baselines 7 updated Run: 2bf2c6e6-92e1-4115-a9eb-8135bd52c1cf Co-authored-by: pauldambra <984817+pauldambra@users.noreply.github.com> --- frontend/snapshots.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index ac69669e4ee1..1b605abd8033 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -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: @@ -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: @@ -12432,6 +12438,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: