From 3b95dc71a3c59048dc778d35480a8947822aa795 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Tue, 22 Sep 2026 18:07:24 +0100 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 0e623e39f61736eeb97f5e3ce8e5f81aa1f6c2a6 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 9 Oct 2026 23:41:53 +0100 Subject: [PATCH 15/17] 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 16/17] 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 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 17/17] 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: