diff --git a/dev/fixtures/chart-legends.tsx b/dev/fixtures/chart-legends.tsx index 6327ed1e..8ed0edfa 100644 --- a/dev/fixtures/chart-legends.tsx +++ b/dev/fixtures/chart-legends.tsx @@ -125,7 +125,7 @@ createRoot(document.getElementById('root')!).render( {serviceSeries.map(series => ( - + {series.label} ))} diff --git a/dev/fixtures/gallery-basics.tsx b/dev/fixtures/gallery-basics.tsx index ef41920c..386de16b 100644 --- a/dev/fixtures/gallery-basics.tsx +++ b/dev/fixtures/gallery-basics.tsx @@ -4,6 +4,9 @@ import { BarChart, AreaChart, PieChart, + FunnelChart, + RetentionChart, + JourneyChart, ScatterChart, ComposedChart, } from '@altertable/data-app/react/ui'; @@ -250,6 +253,210 @@ export function GalleryBasics() { ariaLabel="Workspace performance" /> + + + + + + + + + + diff --git a/dev/screenshots/funnel-retention-charts.png b/dev/screenshots/funnel-retention-charts.png new file mode 100644 index 00000000..cf0754da Binary files /dev/null and b/dev/screenshots/funnel-retention-charts.png differ diff --git a/dev/screenshots/journey-chart.png b/dev/screenshots/journey-chart.png new file mode 100644 index 00000000..379902b7 Binary files /dev/null and b/dev/screenshots/journey-chart.png differ diff --git a/docs/ui.md b/docs/ui.md index 6bec5567..d522345f 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -100,6 +100,20 @@ the samples. Pie slices represent mutually exclusive parts of one total; shares use the sum of supplied items, so include Other when showing a subset of the whole. Scatter points represent independent X/Y observations. +### Product analytics charts + +Use ``, ``, and `` from `/react/ui` +inside ``. Derive inputs from the displayed rows; the +binding owns loading, evidence, CSV, and story content. Types and JSDoc describe +input constraints; the gallery includes complete examples. + +- `` compares ordered step counts across populations. Each series + uses its first step as the baseline; hatching shows previous-step drop-off. +- `` plots query-defined rates and retained counts by interval + offset. Use `null` for unobserved periods; `incomplete` produces a dotted tail. +- `` derives expandable branches and outcomes from full paths. + Truncated paths stop at the last loaded event. + ### Composed charts Use `` from `/react/ui` for multiple series or mixed marks inside @@ -134,10 +148,11 @@ its columns aligned with the displayed measures. ### Legends -Include `` to derive labels and markers from the series; +Include `` to derive labels and colored square markers from the series; omit it when no legend is needed. For any chart, compose `` with ``, ``, and `` using the -same names and colors as the plot. +same names and colors as the plot. `` defaults to a square, +shared with chart tooltips and funnel summaries. `` includes a legend by default; `showLegend={false}` omits it. Legends align cells in a container-responsive grid, truncate labels, and reserve diff --git a/docs/widgets.md b/docs/widgets.md index 886e321f..9751706b 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -28,18 +28,21 @@ Choose the component by the reader's question. Compose dataset visuals inside charts come from `/react/ui`; ``, ``, and `` are also available from `/react`. -| Question or need | Component | Guidance | -| --------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| How do categories compare? | `` | Compare nonnegative category values; order by value for rank or by a meaningful category order. | -| Which items lead? | `` | Use a compact ordered list with values and optional detail. Tracks scale to the largest visible item, not a total. | -| How does a measure change over time? | `` | Use ordered, equally spaced samples to emphasize the trend. | -| How large is the measure over time? | `` | Use the same samples as a line chart when filled magnitude relative to zero helps answer the question. | -| What makes up the whole? | `` | Use a few mutually exclusive parts of one total; include the remainder as Other. | -| What share of an observed total does each part represent? | `` | Use a compact display of values and shares with an explicit total, including when only some parts are shown. | -| How do several measures relate on one plot? | `` | Compose bars, lines, areas, or scatter series; label units clearly and use separate axes for different units. | -| How are two numeric measures related? | `` | Use independent X/Y observations to explore relationships, clusters, and outliers. | -| How did one metric change between periods? | `` | Use the metric reading and its displayed comparison period. | -| What are the exact values or row details? | `` | Use a bound table for lookup, search, and precise comparisons. | +| Question or need | Component | Guidance | +| --------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------ | +| How do categories compare? | `` | Compare nonnegative category values; order by value for rank or by a meaningful category order. | +| Which items lead? | `` | Use a compact ordered list with values and optional detail. Tracks scale to the largest visible item, not a total. | +| How does a measure change over time? | `` | Use ordered, equally spaced samples to emphasize the trend. | +| How large is the measure over time? | `` | Use the same samples as a line chart when filled magnitude relative to zero helps answer the question. | +| What makes up the whole? | `` | Use a few mutually exclusive parts of one total; include the remainder as Other. | +| What share of an observed total does each part represent? | `` | Use a compact display of values and shares with an explicit total, including when only some parts are shown. | +| How do several measures relate on one plot? | `` | Compose bars, lines, areas, or scatter series; label units clearly and use separate axes for different units. | +| How are two numeric measures related? | `` | Use independent X/Y observations to explore relationships, clusters, and outliers. | +| Where do users drop out of a sequence? | `` | Use ordered counts from the same population reaching each successive step. | +| Do cohorts return over time? | `` | Use query-defined rates and retained counts by numeric offset; mark incomplete periods. | +| Which paths do users take? | `` | Use full paths with population counts and outcomes; explore branches step by step. | +| How did one metric change between periods? | `` | Use the metric reading and its displayed comparison period. | +| What are the exact values or row details? | `` | Use a bound table for lookup, search, and precise comparisons. | Prefer bars or a ranking when readers need to compare similarly sized categories; use a pie for a simple composition question. Prefer a line when the trend is diff --git a/src/react/style-contract.ts b/src/react/style-contract.ts index 0be88a86..7c637deb 100644 --- a/src/react/style-contract.ts +++ b/src/react/style-contract.ts @@ -147,6 +147,9 @@ export const dataAppStyleClasses = { component: 'ChartLegend.Label', kind: 'root', }, + 'altertable-funnel-chart': { component: 'FunnelChart', kind: 'root' }, + 'altertable-retention-chart': { component: 'RetentionChart', kind: 'root' }, + 'altertable-journey-chart': { component: 'JourneyChart', kind: 'root' }, 'altertable-composed-chart': { component: 'ComposedChart', kind: 'root', diff --git a/src/react/styles.css b/src/react/styles.css index b688d2d6..374f086f 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -59,6 +59,7 @@ @import './ui/PieChart.css' layer(atbl.components); @import './ui/ScatterChart.css' layer(atbl.components); @import './ui/ComposedChart.css' layer(atbl.components); +@import './ui/AnalyticsCharts.css' layer(atbl.components); @import './annotations/colors.css' layer(atbl.components); @import './annotations/AnnotationBar.css' layer(atbl.components); @import './annotations/AnnotationControls.css' layer(atbl.components); diff --git a/src/react/ui/AnalyticsCharts.css b/src/react/ui/AnalyticsCharts.css new file mode 100644 index 00000000..5f3f0fd2 --- /dev/null +++ b/src/react/ui/AnalyticsCharts.css @@ -0,0 +1,109 @@ +.altertable-funnel-chart, +.altertable-retention-chart, +.altertable-journey-chart { + min-width: 0; + font-size: var(--atbl-type-meta-size); + font-variant-numeric: tabular-nums; +} +.altertable-funnel-summary { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; +} +.altertable-funnel-summary > div { + display: grid; + gap: 4px; + min-width: 0; + overflow-wrap: anywhere; +} +.altertable-funnel-summary span { + color: var(--atbl-muted); +} +.altertable-funnel-summary .altertable-chart-legend-marker { + margin-right: 6px; +} +.altertable-funnel-summary strong { + font-size: var(--atbl-type-section-size); + font-weight: var(--atbl-type-emphasis-weight); +} +.altertable-funnel-scroll, +.altertable-journey-scroll { + width: 100%; + overflow: auto; +} +.altertable-journey-scroll { + max-height: 900px; +} +.altertable-analytics-note { + color: var(--atbl-muted); + margin: 8px 0; + overflow-wrap: anywhere; +} +.altertable-journey-plot { + position: relative; +} +.altertable-journey-plot > svg { + position: absolute; + inset: 0; +} +.altertable-journey-stage { + position: absolute; + top: 0; + color: var(--atbl-muted); +} +.altertable-journey-node { + position: absolute; +} +.altertable-journey-bar { + position: absolute; + inset-block: 0; + left: 0; + background: var(--atbl-muted); +} +.altertable-journey-bar[data-event] { + background: var(--atbl-chart-5); +} +.altertable-journey-label { + position: absolute; + bottom: 100%; + margin-bottom: 8px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + width: 100%; + box-sizing: border-box; + min-height: 64px; + padding: 8px 36px 8px 8px; + border: 1px solid var(--atbl-border); + border-radius: var(--atbl-radius-surface); + background: var(--atbl-surface); + color: var(--atbl-muted); + overflow-wrap: anywhere; +} +.altertable-journey-name { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + color: var(--atbl-text); + font-weight: var(--atbl-type-label-weight); +} +.altertable-journey-expand { + position: absolute; + right: 4px; + bottom: 100%; + margin-bottom: 12px; +} +.altertable-journey-more { + position: absolute; + top: 100%; + left: 0; + margin-top: 8px; +} + +.altertable-funnel-tooltip-detail { + display: block; + color: var(--atbl-muted); + overflow-wrap: anywhere; +} diff --git a/src/react/ui/ChartLegend.css b/src/react/ui/ChartLegend.css index ace48860..b60d5eac 100644 --- a/src/react/ui/ChartLegend.css +++ b/src/react/ui/ChartLegend.css @@ -38,10 +38,15 @@ opacity: var(--atbl-control-disabled-opacity); } .altertable-chart-legend-marker { - display: block; + display: inline-block; + width: 8px; + height: 8px; + vertical-align: middle; + flex: none; +} +.altertable-chart-legend-marker:not([data-kind='square']) { width: 20px; height: 12px; - flex: none; } .altertable-chart-legend-label { min-width: 0; diff --git a/src/react/ui/ChartLegend.tsx b/src/react/ui/ChartLegend.tsx index bce383ce..75f7520c 100644 --- a/src/react/ui/ChartLegend.tsx +++ b/src/react/ui/ChartLegend.tsx @@ -6,7 +6,7 @@ import { type ReactNode, } from 'react'; import { classNames } from '@/src/react/ui/classNames'; -import { chartBarFill } from '@/src/react/ui/chart-primitives'; +import { chartBarFill } from '@/src/react/ui/chartColor'; export type ChartLegendProps = { children: ReactNode; @@ -85,18 +85,24 @@ function ChartLegendItem({ ); } -export type ChartLegendMarkerKind = 'bar' | 'line' | 'area' | 'point' | 'slice'; +export type ChartLegendMarkerKind = + | 'square' + | 'bar' + | 'line' + | 'area' + | 'point' + | 'slice'; export type ChartLegendMarkerProps = Omit< ComponentPropsWithRef<'svg'>, 'children' > & { - kind: ChartLegendMarkerKind; + kind?: ChartLegendMarkerKind; }; /** Decorative mark; color accepts public palette tokens or any CSS color. * Defaults match the shared bar or accent-colored series primitives. */ function ChartLegendMarker({ - kind, + kind = 'square', color = kind === 'bar' ? chartBarFill : 'var(--atbl-accent)', className, style, @@ -107,11 +113,12 @@ function ChartLegendMarker({ {...props} aria-hidden="true" focusable="false" - viewBox="0 0 20 12" + viewBox={kind === 'square' ? '0 0 8 8' : '0 0 20 12'} data-kind={kind} className={classNames('altertable-chart-legend-marker', className)} style={{ color, ...style }} > + {kind === 'square' && } {kind === 'bar' && ( )} diff --git a/src/react/ui/ComposedChart.css b/src/react/ui/ComposedChart.css index 3b4bc8aa..c05bffff 100644 --- a/src/react/ui/ComposedChart.css +++ b/src/react/ui/ComposedChart.css @@ -20,3 +20,11 @@ .altertable-composed-chart-legend-above { padding-bottom: var(--atbl-space-md); } + +/* A pointer press can retain :focus-visible on an already keyboard-focused SVG. */ +.altertable-composed-chart[data-atbl-internal-pointer-focus] { + --atbl-focus-outline: none; +} +.altertable-composed-chart [data-atbl-focus='inset']:focus:not(:focus-visible) { + outline: none; +} diff --git a/src/react/ui/ComposedChart.tsx b/src/react/ui/ComposedChart.tsx index 3e7d059f..75f24ad9 100644 --- a/src/react/ui/ComposedChart.tsx +++ b/src/react/ui/ComposedChart.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'react'; +import { useState, type ComponentProps } from 'react'; import { ComposedChartLegend as Legend } from '@/src/react/ui/ComposedChartLegend'; import { ComposedChart as RechartsComposedChart, @@ -51,12 +51,20 @@ function ComposedChartRoot({ className, ...props }: ComposedChartProps) { + const [pointerFocus, setPointerFocus] = useState(false); return (
setPointerFocus(true)} + onKeyDownCapture={() => setPointerFocus(false)} + onBlurCapture={event => { + if (!event.currentTarget.contains(event.relatedTarget)) + setPointerFocus(false); + }} > { if (entry.type === 'none') return null; const type = iconType ?? entry.type; - const kind = legendMarkerKinds[type ?? 'none'] ?? 'point'; + const kind = iconType + ? (legendMarkerKinds[type ?? 'none'] ?? 'point') + : 'square'; const formatLabel = entry.formatter ?? formatter; const label = formatLabel ? formatLabel(entry.value, entry, index) @@ -72,7 +74,10 @@ function LegendContent({ ))} {label} @@ -121,7 +126,7 @@ export function ComposedChartLegend({ }: ComposedChartLegendProps) { return ( } {...props} diff --git a/src/react/ui/FunnelChart.tsx b/src/react/ui/FunnelChart.tsx new file mode 100644 index 00000000..b33731e3 --- /dev/null +++ b/src/react/ui/FunnelChart.tsx @@ -0,0 +1,323 @@ +import { Fragment, useEffect, useId, useRef, useState } from 'react'; +import { invariant } from '@/src/core/invariant'; +import { ChartLegend } from '@/src/react/ui/ChartLegend'; +import { ComposedChart } from '@/src/react/ui/ComposedChart'; +import { ChartTooltipContent } from '@/src/react/ui/chart-primitives'; +import { classNames } from '@/src/react/ui/classNames'; +import { + type AnalyticsChartProps, + formatAnalyticsValue, + formatAnalyticsRate, + validateAnalyticsCount, + validateAnalyticsIds, +} from '@/src/react/ui/analytics-chart-data'; + +/** Ordered step with a unique, nonblank ID. */ +export type FunnelChartStep = { id: string; label: string }; +export type FunnelChartSeries = { + /** Unique, nonblank population ID. */ + id: string; + label: string; + /** Finite, nonnegative counts aligned with steps and nonincreasing. */ + values: readonly number[]; +}; +export type FunnelChartProps = AnalyticsChartProps & { + steps: readonly FunnelChartStep[]; + series: readonly FunnelChartSeries[]; +}; + +/** Conversion columns with previous-step drop-off, normalized to each series' first step. + * Owns series highlighting and portion-specific inspection; compose inside VisualizationWidget. */ +export function FunnelChart({ + steps, + series, + unit, + ariaLabel, + className, + formatValue = formatAnalyticsValue, +}: FunnelChartProps) { + const chartId = useId().replace(/[^a-zA-Z0-9_-]/g, ''); + const [highlighted, setHighlighted] = useState<{ + id: string; + conversion: boolean; + step: number; + } | null>(null); + const clearTimer = useRef | null>(null); + function cancelClear() { + if (clearTimer.current !== null) clearTimeout(clearTimer.current); + clearTimer.current = null; + } + useEffect(() => cancelClear, []); + function highlight(id: string, conversion: boolean, step: number) { + cancelClear(); + setHighlighted({ id, conversion, step }); + } + function clearHighlight() { + cancelClear(); + setHighlighted(null); + } + function scheduleClear() { + cancelClear(); + clearTimer.current = setTimeout(() => setHighlighted(null), 100); + } + validateAnalyticsIds('funnel step', steps); + validateAnalyticsIds('funnel series', series); + for (const item of series) { + invariant( + item.values.length === steps.length, + 'funnel chart counts must align with steps.' + ); + item.values.forEach((value, index) => { + validateAnalyticsCount('funnel', value); + invariant( + index === 0 || value <= item.values[index - 1]!, + 'funnel chart counts must be nonincreasing.' + ); + }); + } + function reading(item: FunnelChartSeries, index: number) { + const first = item.values[0] ?? 0; + const current = item.values[index]!; + const previous = item.values[Math.max(0, index - 1)]!; + return { + current, + previous, + dropped: previous - current, + conversion: first > 0 ? current / first : null, + dropOff: first > 0 ? (previous - current) / first : null, + }; + } + const data = steps.map((step, index) => ({ + label: step.label, + index, + ...Object.fromEntries( + series.flatMap((item, seriesIndex) => { + const value = reading(item, index); + return [ + [`conversion${seriesIndex}`, (value.conversion ?? 0) * 100], + [ + `drop${seriesIndex}`, + (value.dropOff ?? (index === 0 ? 1 : 0)) * 100, + ], + ]; + }) + ), + })); + return ( +
+ {steps.length === 0 || series.length === 0 ? ( + No data + ) : ( + <> +
+ {series.map((item, index) => ( +
+ + + {item.label} + + + {formatAnalyticsRate( + reading(item, steps.length - 1).conversion + )} + +
+ ))} +
+

+ {steps[0]!.label} → {steps.at(-1)!.label} +

+
+
+ + + + `${value}%`} + /> + + {series.map((item, index) => ( + + + + + + + + + + + ))} + + {series.map((item, index) => ( + + + highlight(item.id, true, bar.payload.index) + } + onMouseLeave={scheduleClear} + onClick={bar => + highlight(item.id, true, bar.payload.index) + } + /> + + highlight(item.id, false, bar.payload.index) + } + onMouseLeave={scheduleClear} + onClick={bar => + highlight(item.id, false, bar.payload.index) + } + radius={0} + maxBarSize={undefined} + activeBar={false} + /> + + ))} + { + const index = payload?.[0]?.payload.index; + if (!active || typeof index !== 'number') return null; + const tooltipItems = series + .filter( + item => + !highlighted || + highlighted.step !== index || + item.id === highlighted.id + ) + .flatMap(item => { + const value = reading(item, index); + const selected = highlighted?.step === index; + const readings = selected + ? [highlighted.conversion] + : [true, false]; + const seriesIndex = series.indexOf(item); + return readings.map(conversion => ({ + name: item.label, + graphicalItemId: `${item.id}-${conversion ? 'conversion' : 'drop-off'}`, + dataKey: `${item.id}-${conversion ? 'conversion' : 'drop-off'}`, + color: `var(--atbl-chart-${(seriesIndex % 8) + 1})`, + value: + (conversion ? value.conversion : value.dropOff) ?? + 0, + formatter: () => ( + <> + {formatAnalyticsRate( + conversion ? value.conversion : value.dropOff + )}{' '} + {conversion ? 'conversion' : 'drop-off'} + + {formatValue( + conversion ? value.current : value.dropped + )}{' '} + of {formatValue(value.previous)} {unit} + + + ), + })); + }); + return ( + + ); + }} + /> + +
+
+
    + {steps.map((step, index) => ( +
  • + {step.label} + {series.map(item => { + const value = reading(item, index); + return ( + + {' '} + · {item.label}: {formatValue(value.current)} {unit},{' '} + {formatAnalyticsRate(value.conversion)} conversion,{' '} + {formatValue(value.dropped)} dropped off + + ); + })} +
  • + ))} +
+ + )} +
+ ); +} diff --git a/src/react/ui/JourneyChart.tsx b/src/react/ui/JourneyChart.tsx new file mode 100644 index 00000000..bb709a35 --- /dev/null +++ b/src/react/ui/JourneyChart.tsx @@ -0,0 +1,316 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { invariant } from '@/src/core/invariant'; +import { Button } from '@/src/react/ui/Button'; +import { classNames } from '@/src/react/ui/classNames'; +import { + type AnalyticsChartProps, + formatAnalyticsValue, + formatAnalyticsRate, + validateAnalyticsCount, + validateAnalyticsIds, +} from '@/src/react/ui/analytics-chart-data'; +import { + buildJourneyFlow, + limitJourneyFlow, + type JourneyChartPath, + type JourneyNode, +} from '@/src/react/ui/journey-flow'; + +export type { JourneyChartPath } from '@/src/react/ui/journey-flow'; +export type JourneyChartProps = AnalyticsChartProps & { + /** Full paths and their population counts. Branches and outcomes are derived internally. */ + paths: readonly JourneyChartPath[]; +}; + +/** Path exploration with two initial steps, expandable prefixes, and three events per depth. + * Counts and flows use the Step 1 population. Compose inside VisualizationWidget. */ +export function JourneyChart({ + paths, + unit, + ariaLabel, + className, + formatValue = formatAnalyticsValue, +}: JourneyChartProps) { + validateAnalyticsIds('journey path', paths); + let total = 0; + for (const path of paths) { + validateAnalyticsCount('journey', path.count); + total += path.count; + invariant( + path.steps.length > 0 && + path.steps.every(step => step.event.trim().length > 0), + 'journey chart paths require nonblank events.' + ); + } + invariant( + Number.isFinite(total), + 'journey chart total count must be finite.' + ); + // A changed displayed result starts a new exploration, including in inspection views. + return ( +
+ {paths.length === 0 ? ( + No data + ) : ( + + )} +
+ ); +} + +function JourneyFlow({ + paths, + unit, + formatValue, +}: Pick & { + formatValue: (value: number) => string; +}) { + const [expandedBranch, setExpandedBranch] = useState([]); + const [visibleEventsByDepth, setVisibleEventsByDepth] = useState< + Record + >({}); + const graph = limitJourneyFlow( + buildJourneyFlow(paths, expandedBranch), + visibleEventsByDepth + ); + const scrollRef = useRef(null); + const pendingDepth = useRef(null); + const depthCount = Math.max(0, ...graph.nodes.map(node => node.depth)) + 1; + const startingCount = graph.nodes + .filter(node => node.depth === 0) + .reduce((sum, node) => sum + node.count, 0); + function percent(count: number) { + return formatAnalyticsRate( + startingCount > 0 ? count / startingCount : null + ); + } + const nodeWidth = 176; + const nodeHeight = 96; + const barWidth = 16; + const rowSpacing = nodeHeight + 80; + const columnSpacing = nodeWidth + 112; + const width = 34 + nodeWidth + (depthCount - 1) * columnSpacing; + function columnX(depth: number) { + return 10 + depth * columnSpacing; + } + useLayoutEffect(() => { + const depth = pendingDepth.current; + const container = scrollRef.current; + if (depth === null || !container || depth >= depthCount) return; + pendingDepth.current = null; + const left = columnX(depth); + const right = left + nodeWidth + 24; + const nextLeft = + left < container.scrollLeft + ? left - 24 + : right > container.scrollLeft + container.clientWidth + ? right - container.clientWidth + : container.scrollLeft; + if (nextLeft !== container.scrollLeft) + container.scrollTo({ + left: Math.max(0, nextLeft), + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches + ? 'instant' + : 'smooth', + }); + }); + const columns = Array.from({ length: depthCount }, (_, depth) => + graph.nodes + .filter(node => node.depth === depth) + .sort((a, b) => { + function rank(node: JourneyNode) { + return node.outcome === 'event' + ? 0 + : node.outcome === 'other' + ? 1 + : 2; + } + return ( + rank(a) - rank(b) || b.count - a.count || a.key.localeCompare(b.key) + ); + }) + ); + const positions = new Map< + string, + { node: JourneyNode; x: number; y: number } + >(); + columns.forEach((column, depth) => { + const parent = columns[depth - 1]?.find(node => node.expanded); + const parentY = parent ? positions.get(parent.key)?.y : undefined; + const top = Math.max( + 104, + (parentY ?? 104) - (column.length > 1 ? rowSpacing : 0) + ); + column.forEach((node, index) => + positions.set(node.key, { + node, + x: columnX(depth), + y: top + index * rowSpacing, + }) + ); + }); + const height = Math.max( + 280, + ...[...positions.values()].map(position => position.y + nodeHeight + 40) + ); + const outgoingOffsets = new Map(); + const incomingOffsets = new Map(); + const orderedLinks = [...graph.links].sort( + (a, b) => + (positions.get(a.source)?.y ?? 0) - (positions.get(b.source)?.y ?? 0) || + (positions.get(a.target)?.y ?? 0) - (positions.get(b.target)?.y ?? 0) + ); + const links = orderedLinks.flatMap(link => { + const source = positions.get(link.source); + const target = positions.get(link.target); + if (!source || !target || link.count <= 0 || startingCount <= 0) return []; + const x0 = source.x + barWidth; + const x1 = target.x; + const mid = (x0 + x1) / 2; + const sourceTop = source.y + (outgoingOffsets.get(link.source) ?? 0); + const targetTop = target.y + (incomingOffsets.get(link.target) ?? 0); + const thickness = (link.count / startingCount) * nodeHeight; + outgoingOffsets.set( + link.source, + (outgoingOffsets.get(link.source) ?? 0) + thickness + ); + incomingOffsets.set( + link.target, + (incomingOffsets.get(link.target) ?? 0) + thickness + ); + return [ + { + muted: target.node.outcome === 'end' || target.node.outcome === 'other', + d: `M${x0},${sourceTop} C${mid},${sourceTop} ${mid},${targetTop} ${x1},${targetTop} L${x1},${targetTop + thickness} C${mid},${targetTop + thickness} ${mid},${sourceTop + thickness} ${x0},${sourceTop + thickness} Z`, + }, + ]; + }); + return ( +
+
+ + {columns.map((column, depth) => ( + + {column.length > 0 && + column.every( + node => node.outcome !== 'event' && node.outcome !== 'other' + ) + ? 'Outcome' + : `Step ${depth + 1}`} + + ))} + {[...positions.values()].map(({ node, x, y }) => ( +
0 + ? (node.count / startingCount) * nodeHeight + : 0, + }} + > + + ))} +
+
+ ); +} diff --git a/src/react/ui/PieChart.tsx b/src/react/ui/PieChart.tsx index 8d65fdfd..adf4f50c 100644 --- a/src/react/ui/PieChart.tsx +++ b/src/react/ui/PieChart.tsx @@ -151,7 +151,7 @@ export function PieChart({ tabIndex={-1} aria-label={`${item.label}: ${formatValue(item.value)} ${unit}, ${percent(item.share)}`} > - + {item.label} diff --git a/src/react/ui/RetentionChart.tsx b/src/react/ui/RetentionChart.tsx new file mode 100644 index 00000000..a3a9c5eb --- /dev/null +++ b/src/react/ui/RetentionChart.tsx @@ -0,0 +1,214 @@ +import { Fragment } from 'react'; +import { invariant } from '@/src/core/invariant'; +import { ComposedChart } from '@/src/react/ui/ComposedChart'; +import { ChartLegend } from '@/src/react/ui/ChartLegend'; +import { TooltipSurface } from '@/src/react/ui/TooltipSurface'; +import { classNames } from '@/src/react/ui/classNames'; +import { + type AnalyticsChartProps, + formatAnalyticsValue, + formatAnalyticsRate, + validateAnalyticsCount, + validateAnalyticsIds, +} from '@/src/react/ui/analytics-chart-data'; + +export type RetentionChartPoint = { + /** Nonnegative integer, unique per series; uneven offsets retain their distance. */ + offset: number; + /** Must agree across series for the same offset. */ + label: string; + /** Observed retention fraction in [0, 1], or null for an unobserved period. */ + rate: number | null; + /** Finite count in [0, cohortSize]; null exactly when rate is null. */ + retainedCount: number | null; + /** Observed but still accumulating; drawn as a dotted tail. */ + incomplete?: boolean; +}; +export type RetentionChartSeries = { + /** Unique, nonblank cohort or segment ID. */ + id: string; + label: string; + /** Finite, nonnegative starting population. */ + cohortSize: number; + points: readonly RetentionChartPoint[]; +}; +export type RetentionChartProps = AnalyticsChartProps & { + series: readonly RetentionChartSeries[]; +}; + +/** Query-defined retention curves with count inspection and dotted incomplete periods. + * Rates are not recomputed from counts. Compose inside VisualizationWidget. */ +export function RetentionChart({ + series, + unit, + ariaLabel, + className, + formatValue = formatAnalyticsValue, +}: RetentionChartProps) { + validateAnalyticsIds('retention series', series); + const offsets = new Map(); + for (const item of series) { + validateAnalyticsCount('retention', item.cohortSize); + const seen = new Set(); + for (const point of item.points) { + invariant( + Number.isSafeInteger(point.offset) && + point.offset >= 0 && + !seen.has(point.offset), + 'retention chart offsets must be unique nonnegative integers within each series.' + ); + seen.add(point.offset); + invariant( + point.rate === null || + (Number.isFinite(point.rate) && point.rate >= 0 && point.rate <= 1), + 'retention chart rates must be finite fractions between 0 and 1, or null.' + ); + invariant( + (point.rate === null) === (point.retainedCount === null), + 'retention chart unobserved rates and counts must both be null.' + ); + if (point.retainedCount !== null) { + validateAnalyticsCount('retention', point.retainedCount); + invariant( + point.retainedCount <= item.cohortSize, + 'retention chart retained counts cannot exceed cohort size.' + ); + } + invariant( + !offsets.has(point.offset) || offsets.get(point.offset) === point.label, + 'retention chart labels must agree for the same offset.' + ); + offsets.set(point.offset, point.label); + } + } + const data: ({ offset: number; label: string } & Record< + string, + number | string | null + >)[] = [...offsets] + .sort(([a], [b]) => a - b) + .map(([offset, label]) => ({ + offset, + label, + ...Object.fromEntries( + series.flatMap((_, index) => [ + [`rate${index}`, null], + [`tail${index}`, null], + ]) + ), + })); + series.forEach((item, index) => { + const points = [...item.points].sort((a, b) => a.offset - b.offset); + points.forEach((point, pointIndex) => { + const row = data.find(row => row.offset === point.offset)!; + row[point.incomplete ? `tail${index}` : `rate${index}`] = + point.rate === null ? null : point.rate * 100; + if (point.incomplete && pointIndex > 0) { + const previous = points[pointIndex - 1]!; + data.find(row => row.offset === previous.offset)![`tail${index}`] = + previous.rate === null ? null : previous.rate * 100; + } + }); + }); + return ( +
+ {data.length === 0 ? ( + No data + ) : ( + <> + + + + offsets.get(Number(value)) ?? + (data[0]?.label.replace(/\d+(?:\.\d+)?/, String(value)) || + String(value)) + } + /> + `${value}%`} + /> + {series.map((item, index) => ( + + + + + ))} + { + const offset = payload?.[0]?.payload.offset; + if (!active || typeof offset !== 'number') return null; + return ( + + {offsets.get(offset)} + {series.map(item => { + const point = item.points.find( + point => point.offset === offset + ); + if (!point) return null; + return ( + + {item.label}:{' '} + {point.rate === null + ? 'Not observed' + : `${formatAnalyticsRate(point.rate)} · ${formatValue(point.retainedCount!)} / ${formatValue(item.cohortSize)} ${unit}${point.incomplete ? ' · Incomplete period' : ''}`} + + ); + })} + + ); + }} + /> + + + {series.map((item, index) => ( + + + {item.label} + + ))} + +
    + {series.map(item => ( +
  • + {item.label} + {item.points.map(point => ( + + {' '} + · {point.label}:{' '} + {point.rate === null + ? 'Not observed' + : `${formatAnalyticsRate(point.rate)} · ${formatValue(point.retainedCount!)} / ${formatValue(item.cohortSize)} ${unit}${point.incomplete ? ' · Incomplete period' : ''}`} + + ))} +
  • + ))} +
+ + )} +
+ ); +} diff --git a/src/react/ui/Tooltip.css b/src/react/ui/Tooltip.css index 279db20b..6068b79a 100644 --- a/src/react/ui/Tooltip.css +++ b/src/react/ui/Tooltip.css @@ -46,3 +46,19 @@ animation: none; } } + +.altertable-chart-tooltip-row { + display: flex; + align-items: flex-start; + gap: 6px; +} +.altertable-chart-tooltip-marker { + width: 8px; + height: 8px; + margin-top: 0.3em; + flex-shrink: 0; +} +.altertable-chart-tooltip-reading { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/src/react/ui/analytics-chart-data.ts b/src/react/ui/analytics-chart-data.ts new file mode 100644 index 00000000..b81214fb --- /dev/null +++ b/src/react/ui/analytics-chart-data.ts @@ -0,0 +1,44 @@ +import { invariant } from '@/src/core/invariant'; + +export type AnalyticsChartProps = { + /** Accessible measure and scope description. */ + ariaLabel: string; + /** Population unit, such as users or sessions. */ + unit: string; + /** Formats counts; rates use percentage formatting. */ + formatValue?: (value: number) => string; + className?: string; +}; + +export function formatAnalyticsValue(value: number) { + return new Intl.NumberFormat().format(value); +} +export function formatAnalyticsRate(value: number | null) { + return value === null + ? '—' + : new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits: 1, + }).format(value); +} + +export function validateAnalyticsIds( + kind: string, + items: readonly { id: string }[] +) { + const ids = new Set(); + for (const { id } of items) { + invariant( + id.trim().length > 0 && !ids.has(id), + `${kind} chart IDs must be nonblank and unique.` + ); + ids.add(id); + } +} + +export function validateAnalyticsCount(kind: string, value: number) { + invariant( + Number.isFinite(value) && value >= 0, + `${kind} chart counts must be finite and nonnegative.` + ); +} diff --git a/src/react/ui/chart-primitives.tsx b/src/react/ui/chart-primitives.tsx index ab466252..baa0a8db 100644 --- a/src/react/ui/chart-primitives.tsx +++ b/src/react/ui/chart-primitives.tsx @@ -15,10 +15,11 @@ import { type LineProps, type AreaProps, } from 'recharts'; +import { ChartLegend } from '@/src/react/ui/ChartLegend'; import { TooltipSurface } from '@/src/react/ui/TooltipSurface'; -export const chartBarFill = - 'var(--atbl-chart-fill, color-mix(in srgb, var(--atbl-accent) 75%, var(--atbl-surface)))'; +import { chartBarFill } from '@/src/react/ui/chartColor'; +export { chartBarFill } from '@/src/react/ui/chartColor'; const accent = 'var(--atbl-accent)'; /** Hollow sample marker shared by standalone and composed series. */ export function ChartDot({ @@ -153,7 +154,7 @@ export function ChartGrid(props: ComponentProps) { ); } -function ChartTooltipContent( +export function ChartTooltipContent( props: ComponentProps & { active?: boolean } ) { if (!props.active) return null; @@ -161,6 +162,48 @@ function ChartTooltipContent( ({ + ...entry, + unit: undefined, + formatter: (value, name, _item, index, payload) => { + const formatter = entry.formatter ?? props.formatter; + const formatted = formatter + ? formatter(value, name, entry, index, props.payload ?? payload) + : Array.isArray(value) + ? value.join(' ~ ') + : value; + if (formatted == null) return null; + const [displayValue, displayName] = Array.isArray(formatted) + ? formatted + : [formatted, name]; + return [ + + + + {displayName} + {displayName !== undefined && displayName !== '' + ? (props.separator ?? ' : ') + : ''} + {displayValue} + {entry.unit} + + , + '', + ]; + }, + }))} contentStyle={{ margin: 0, padding: 0, @@ -176,7 +219,12 @@ function ChartTooltipContent( lineHeight: 1.2, ...props.labelStyle, }} - itemStyle={{ paddingTop: 2, paddingBottom: 0, ...props.itemStyle }} + itemStyle={{ + color: 'var(--atbl-text)', + paddingTop: 2, + paddingBottom: 0, + ...props.itemStyle, + }} /> ); diff --git a/src/react/ui/chartColor.ts b/src/react/ui/chartColor.ts index 2cb22325..d584327f 100644 --- a/src/react/ui/chartColor.ts +++ b/src/react/ui/chartColor.ts @@ -5,3 +5,6 @@ export function chartColor(id: string): string { return `var(--atbl-chart-${((hash >>> 0) % 8) + 1})`; } + +export const chartBarFill = + 'var(--atbl-chart-fill, color-mix(in srgb, var(--atbl-accent) 75%, var(--atbl-surface)))'; diff --git a/src/react/ui/index.ts b/src/react/ui/index.ts index 7fe7c5cc..01cadaa6 100644 --- a/src/react/ui/index.ts +++ b/src/react/ui/index.ts @@ -179,3 +179,21 @@ export type { ChartLegendMarkerKind, } from '@/src/react/ui/ChartLegend'; export type { ComposedChartLegendProps } from '@/src/react/ui/ComposedChartLegend'; + +export { FunnelChart } from '@/src/react/ui/FunnelChart'; +export type { + FunnelChartProps, + FunnelChartStep, + FunnelChartSeries, +} from '@/src/react/ui/FunnelChart'; +export { RetentionChart } from '@/src/react/ui/RetentionChart'; +export type { + RetentionChartProps, + RetentionChartPoint, + RetentionChartSeries, +} from '@/src/react/ui/RetentionChart'; +export { JourneyChart } from '@/src/react/ui/JourneyChart'; +export type { + JourneyChartProps, + JourneyChartPath, +} from '@/src/react/ui/JourneyChart'; diff --git a/src/react/ui/journey-flow.ts b/src/react/ui/journey-flow.ts new file mode 100644 index 00000000..039b00c8 --- /dev/null +++ b/src/react/ui/journey-flow.ts @@ -0,0 +1,193 @@ +/** Aggregated route; repeated events remain distinct by depth and prefix. */ +export type JourneyChartPath = { + /** Unique, nonblank path ID. */ + id: string; + /** At least one nonblank event; null property means no property grouping. */ + steps: readonly { event: string; property: string | null }[]; + /** Finite, nonnegative population following this route. */ + count: number; + /** True: converted; false: dropped off; null: no conversion classification. */ + converted: boolean | null; + /** Stops at the last loaded event, without adding an outcome or expand control. */ + truncated: boolean; +}; + +export type JourneyNode = { + key: string; + label: string; + depth: number; + outcome: 'event' | 'converted' | 'drop-off' | 'truncated' | 'end' | 'other'; + count: number; + pathKeys: string[]; + branchKeys?: string[]; + expandable?: boolean; + expanded?: boolean; + hiddenBranchCount?: number; +}; + +export function journeyNodes( + path: JourneyChartPath +): Omit[] { + const nodes: Omit[] = path.steps.map( + (step, depth) => ({ + key: JSON.stringify([depth, 'event', step.event, step.property]), + label: + step.property === null + ? step.event + : `${step.event} · ${step.property}`, + depth, + outcome: 'event', + }) + ); + const outcome = path.truncated + ? 'truncated' + : path.converted === false + ? 'drop-off' + : path.converted === true + ? 'converted' + : 'end'; + const label = + outcome === 'truncated' + ? 'More steps' + : outcome === 'drop-off' + ? 'Drop-off' + : outcome === 'converted' + ? 'Converted' + : 'End of path'; + nodes.push({ + key: JSON.stringify([nodes.length, outcome]), + label, + depth: nodes.length, + outcome, + }); + return nodes; +} + +export function buildJourneyFlow( + paths: readonly JourneyChartPath[], + expandedBranch?: string[] +) { + const nodes = new Map(); + const links = new Map< + string, + { + key: string; + source: string; + target: string; + count: number; + pathKeys: string[]; + } + >(); + for (const path of paths) { + const fullSequence = journeyNodes(path); + if (path.truncated) fullSequence.pop(); + const sequence = + expandedBranch === undefined + ? fullSequence + : fullSequence.flatMap((node, index) => { + const branchKeys = fullSequence + .slice(0, index + 1) + .map(step => step.key); + const parentExpanded = + index <= 1 || + branchKeys + .slice(0, index) + .every((key, depth) => expandedBranch[depth] === key); + if (!parentExpanded) return []; + return [ + { + ...node, + key: JSON.stringify(branchKeys), + branchKeys, + expandable: index >= 1 && index < fullSequence.length - 1, + expanded: branchKeys.every( + (key, depth) => expandedBranch[depth] === key + ), + }, + ]; + }); + sequence.forEach((node, index) => { + const current = nodes.get(node.key) ?? { + ...node, + count: 0, + pathKeys: [], + }; + current.count += path.count; + if ('expandable' in node && node.expandable) current.expandable = true; + current.pathKeys.push(path.id); + nodes.set(node.key, current); + const previous = sequence[index - 1]; + if (!previous) return; + const key = JSON.stringify([previous.key, node.key]); + const link = links.get(key) ?? { + key, + source: previous.key, + target: node.key, + count: 0, + pathKeys: [], + }; + link.count += path.count; + link.pathKeys.push(path.id); + links.set(key, link); + }); + } + return { nodes: [...nodes.values()], links: [...links.values()] }; +} + +export function limitJourneyFlow( + graph: ReturnType, + visibleEventsByDepth: Record = {} +) { + const nodes: JourneyNode[] = []; + const replacements = new Map(); + for (const depth of new Set(graph.nodes.map(node => node.depth))) { + const column = graph.nodes + .filter(node => node.depth === depth) + .sort( + (a, b) => + Number(Boolean(b.expanded)) - Number(Boolean(a.expanded)) || + b.count - a.count || + a.key.localeCompare(b.key) + ); + const events = column.filter(node => node.outcome === 'event'); + const outcomes = column.filter(node => node.outcome !== 'event'); + const limit = visibleEventsByDepth[depth] ?? 3; + if (events.length <= limit) { + nodes.push(...column); + continue; + } + nodes.push(...events.slice(0, limit), ...outcomes); + const hidden = events.slice(limit); + const key = JSON.stringify(['other', depth]); + hidden.forEach(node => replacements.set(node.key, key)); + nodes.push({ + key, + label: 'More events', + depth, + outcome: 'other', + hiddenBranchCount: hidden.length, + count: hidden.reduce((sum, node) => sum + node.count, 0), + pathKeys: [...new Set(hidden.flatMap(node => node.pathKeys))], + }); + } + const links = new Map(); + for (const link of graph.links) { + const source = replacements.get(link.source) ?? link.source; + const target = replacements.get(link.target) ?? link.target; + const key = JSON.stringify([source, target]); + const existing = links.get(key); + if (existing) { + existing.count += link.count; + existing.pathKeys.push(...link.pathKeys); + } else { + links.set(key, { + ...link, + key, + source, + target, + pathKeys: [...link.pathKeys], + }); + } + } + return { nodes, links: [...links.values()] }; +} diff --git a/tests/public-api/analytics-charts.test.tsx b/tests/public-api/analytics-charts.test.tsx new file mode 100644 index 00000000..56ec5376 --- /dev/null +++ b/tests/public-api/analytics-charts.test.tsx @@ -0,0 +1,200 @@ +import { expect, test } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { + FunnelChart, + RetentionChart, + JourneyChart, + type FunnelChartProps, + type RetentionChartProps, + type JourneyChartProps, +} from '@altertable/data-app/react/ui'; + +const shared = { + ariaLabel: 'Product analytics', + unit: 'users', + formatValue: (value: number) => value.toFixed(1), + className: 'app-chart', +}; +const funnel: FunnelChartProps = { + ...shared, + steps: [ + { id: 'a', label: 'Visit' }, + { id: 'b', label: 'Activate' }, + ], + series: [{ id: 'all', label: 'All users', values: [100, 40] }], +}; +const retention: RetentionChartProps = { + ...shared, + series: [ + { + id: 'a', + label: 'Sep 1', + cohortSize: 100, + points: [ + { offset: 0, label: 'Week 0', rate: 0, retainedCount: 0 }, + { offset: 1, label: 'Week 1', rate: null, retainedCount: null }, + ], + }, + { + id: 'b', + label: 'Sep 8', + cohortSize: 50, + points: [ + { offset: 0, label: 'Week 0', rate: 0.4, retainedCount: 25 }, + { + offset: 1, + label: 'Week 1', + rate: 0.8, + retainedCount: 40, + incomplete: true, + }, + ], + }, + ], +}; +const journey: JourneyChartProps = { + ...shared, + paths: [ + { + id: 'a', + steps: [ + { event: 'Home', property: null }, + { event: 'Signup', property: null }, + { event: 'Home', property: null }, + ], + count: 40, + converted: true, + truncated: false, + }, + { + id: 'b', + steps: [ + { event: 'Home', property: null }, + { event: 'Docs', property: null }, + ], + count: 60, + converted: false, + truncated: false, + }, + ], +}; + +test('funnel preserves frontend conversion and previous-step drop-off for independent populations', () => { + const html = renderToStaticMarkup(); + expect(html).toContain( + 'All users: 40.0 users, 40% conversion, 60.0 dropped off' + ); + expect(html).toContain('app-chart'); + const zero = renderToStaticMarkup( + + ); + expect(zero).toContain('Empty: 0.0 users, — conversion'); + expect(zero).not.toMatch(/NaN|Infinity/); + expect( + renderToStaticMarkup() + ).toContain('No data'); + for (const values of [ + [100], + [100, -1], + [100, NaN], + [100, Infinity], + [100, 101], + ]) + expect(() => + renderToStaticMarkup( + + ) + ).toThrow(); + expect(() => + renderToStaticMarkup( + + ) + ).toThrow(/unique/); +}); + +test('retention preserves query rates, counts, unobserved periods, and incomplete tails', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Week 0: 0% · 0.0 / 100.0 users'); + expect(html).toContain('Week 1: Not observed'); + // Rates may use a survival definition; never silently derive a different rate from counts. + expect(html).toContain('Week 0: 40% · 25.0 / 50.0 users'); + expect(html).toContain('Week 1: 80% · 40.0 / 50.0 users · Incomplete period'); + expect( + renderToStaticMarkup() + ).toContain('No data'); + for (const point of [ + { offset: 0, label: 'Day 0', rate: 2, retainedCount: 1 }, + { offset: 0, label: 'Day 0', rate: 0, retainedCount: 101 }, + { offset: 0, label: 'Day 0', rate: null, retainedCount: 0 }, + { offset: -1, label: 'Day -1', rate: 0, retainedCount: 0 }, + { offset: 0, label: 'Day 0', rate: NaN, retainedCount: 0 }, + ]) + expect(() => + renderToStaticMarkup( + + ) + ).toThrow(); + expect(() => + renderToStaticMarkup( + + ) + ).toThrow(/unique/); +}); + +test('journey starts with two prefix-aware steps and derives population counts and shares', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Home, step 1, 100.0 users'); + expect(html).toContain('Signup, step 2, 40.0 users'); + expect(html).toContain('40%'); + expect(html).not.toContain('Home, step 3'); + expect(html).toContain('More after Signup, step 2'); + expect( + renderToStaticMarkup() + ).toContain('No data'); + const truncated = renderToStaticMarkup( + + ); + expect(truncated).toContain('Start, step 1, 0.0 users'); + expect(truncated).not.toMatch(/NaN|Infinity|Drop-off|More after/); + for (const path of [ + { ...journey.paths[0]!, count: -1 }, + { ...journey.paths[0]!, count: Infinity }, + { ...journey.paths[0]!, steps: [] }, + ]) + expect(() => + renderToStaticMarkup() + ).toThrow(); + expect(() => + renderToStaticMarkup( + + ) + ).toThrow(/unique/); +}); diff --git a/tests/public-api/apps-charts.test.ts b/tests/public-api/apps-charts.test.ts index 5dd06678..caa76b33 100644 --- a/tests/public-api/apps-charts.test.ts +++ b/tests/public-api/apps-charts.test.ts @@ -182,3 +182,255 @@ test('standalone charts retain formatted hover and tap inspection, zero values, await expect.poll(() => current.getByRole('tooltip').count()).toBe(0); } }); + +test('product analytics charts preserve frontend funnel stacks, numeric retention offsets, and branch exploration', async ({ + page, + mobilePage, +}) => { + for (const current of [page, mobilePage]) { + await current.emulateMedia({ reducedMotion: 'reduce' }); + await current.setViewportSize({ width: 320, height: 900 }); + await current.goto('/chart-legends'); + const funnel = current.getByRole('region', { + name: 'Activation funnel plot', + exact: true, + }); + await funnel.getByRole('application').focus(); + await current.keyboard.press('ArrowRight'); + await expect + .poll(() => funnel.getByRole('tooltip').textContent()) + .toContain('Signed up'); + await expect + .poll(() => funnel.getByRole('tooltip').textContent()) + .toContain('600 of 1,000 users'); + const retention = current.getByRole('region', { + name: 'Weekly retention plot', + exact: true, + }); + await retention.getByRole('application').focus(); + await current.keyboard.press('ArrowRight'); + await expect + .poll(() => retention.getByRole('tooltip').textContent()) + .toContain('Sep 8: 60% · 48 / 80 users · Incomplete period'); + await current.keyboard.press('ArrowRight'); + await expect + .poll(() => retention.getByRole('tooltip').textContent()) + .toContain('Sep 1: 0% · 0 / 100 users'); + await expect + .poll(() => retention.getByRole('tooltip').textContent()) + .toContain('Sep 8: Not observed'); + await current.keyboard.press('ArrowRight'); + await expect + .poll(() => retention.getByRole('tooltip').textContent()) + .toContain('Week 7'); + const journey = current.getByRole('region', { + name: 'Onboarding journey', + exact: true, + }); + async function activateBranch(name: string) { + const button = journey.getByRole('button', { name, exact: true }); + if (current === mobilePage) await button.tap(); + else await button.click(); + } + await expect + .poll(() => + journey + .getByRole('region', { + name: 'Signed up, step 1, 1,060 users', + exact: true, + }) + .count() + ) + .toBe(1); + expect( + await journey.getByRole('region', { name: /Report created/ }).count() + ).toBe(0); + await activateBranch('More after Workspace created, step 2'); + await expect + .poll(() => + journey + .getByRole('button', { + name: 'Less after Workspace created, step 2', + exact: true, + }) + .getAttribute('aria-expanded') + ) + .toBe('true'); + await expect + .poll(() => + journey + .getByRole('region', { + name: 'Report created, step 3, 460 users', + exact: true, + }) + .count() + ) + .toBe(1); + await expect + .poll(() => + journey + .getByRole('region', { + name: 'Drop-off, step 3, 180 users', + exact: true, + }) + .count() + ) + .toBe(1); + await activateBranch('More after Report created, step 3'); + await activateBranch('More after Subscribed, step 4'); + await expect + .poll(() => + journey + .getByRole('region', { + name: 'Converted, step 5, 460 users', + exact: true, + }) + .count() + ) + .toBe(1); + await activateBranch('Less after Workspace created, step 2'); + await expect + .poll(() => + journey.getByRole('region', { name: /Report created/ }).count() + ) + .toBe(0); + await activateBranch('Show more events at step 2'); + await expect + .poll(() => + journey + .getByRole('region', { + name: 'Settings, step 2, 20 users', + exact: true, + }) + .count() + ) + .toBe(1); + expect( + await journey + .getByRole('button', { + name: 'More after Settings, step 2', + exact: true, + }) + .count() + ).toBe(0); + await activateBranch('More after Search, step 2'); + await expect + .poll(() => + journey + .getByRole('region', { + name: 'Signed up, step 3, 30 users', + exact: true, + }) + .count() + ) + .toBe(1); + await current + .getByRole('button', { name: 'Switch to dark theme', exact: true }) + .click(); + await expect + .poll(() => + current.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth + ) + ) + .toBe(true); + await current.evaluate(() => { + document.documentElement.style.fontSize = '24px'; + }); + await expect + .poll(() => + current.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth + ) + ) + .toBe(true); + } +}); + +test('funnel inspection follows the hovered or tapped conversion and drop-off portion', async ({ + page, + mobilePage, +}) => { + for (const [current, touch] of [ + [page, false], + [mobilePage, true], + ] as const) { + await current.setViewportSize({ width: 1280, height: 900 }); + await current.goto('/chart-legends'); + const plot = current.getByRole('region', { + name: 'Activation funnel plot', + exact: true, + }); + await plot.scrollIntoViewIfNeeded(); + const visited = await plot + .getByText('Visited', { exact: true }) + .boundingBox(); + const signedUp = await plot + .getByText('Signed up', { exact: true }) + .boundingBox(); + const zero = await plot.getByText('0%', { exact: true }).boundingBox(); + const hundred = await plot.getByText('100%', { exact: true }).boundingBox(); + const center = signedUp!.x + signedUp!.width / 2; + const band = center - (visited!.x + visited!.width / 2); + const x = center - band / 4; + const bottom = zero!.y + zero!.height / 2; + const top = hundred!.y + hundred!.height / 2; + for (const [rate, expected] of [ + [0.3, '600 of 1,000 users'], + [0.8, '400 of 1,000 users'], + ] as const) { + const y = bottom - (bottom - top) * rate; + if (touch) await current.touchscreen.tap(x, y); + else await current.mouse.move(x, y); + await expect + .poll(() => plot.getByRole('tooltip').textContent()) + .toContain(expected); + expect(await plot.getByRole('tooltip').textContent()).not.toContain( + 'Paid plan' + ); + await expect + .poll(() => plot.getByRole('tooltip').textContent()) + .toContain(rate === 0.3 ? '60% conversion' : '40% drop-off'); + } + await plot.getByRole('application').focus(); + await current.keyboard.press('ArrowRight'); + await expect + .poll(() => plot.getByRole('tooltip').textContent()) + .toContain('Paid plan'); + } +}); + +test('chart frames suppress pointer focus outlines and restore keyboard focus', async ({ + page, +}) => { + await page.goto('/chart-legends'); + for (const name of ['Activation funnel plot', 'Weekly retention plot']) { + const plot = page + .getByRole('region', { name, exact: true }) + .getByRole('application'); + await plot.focus(); + await page.keyboard.press('ArrowRight'); + await expect + .poll(() => + plot.evaluate(element => getComputedStyle(element).outlineStyle) + ) + .toBe('solid'); + await plot.click({ position: { x: 10, y: 10 } }); + await plot.focus(); + await expect + .poll(() => + plot.evaluate(element => getComputedStyle(element).outlineStyle) + ) + .toBe('none'); + await page.keyboard.press('ArrowRight'); + await expect + .poll(() => + plot.evaluate(element => getComputedStyle(element).outlineStyle) + ) + .toBe('solid'); + } +}); diff --git a/tests/public-api/chart-legend.test.tsx b/tests/public-api/chart-legend.test.tsx index 0b0b72fe..8bd73d3e 100644 --- a/tests/public-api/chart-legend.test.tsx +++ b/tests/public-api/chart-legend.test.tsx @@ -41,7 +41,7 @@ test('pie category legends use shared primitives and can be omitted', () => { ); } expect(render()).toContain('altertable-chart-legend'); - expect(render()).toContain('data-kind="slice"'); + expect(render()).toContain('data-kind="square"'); expect(render(false)).not.toContain('altertable-chart-legend'); expect(render(false)).toContain('altertable-pie-slice'); }); diff --git a/tests/public-api/fixtures/chart-legends.tsx b/tests/public-api/fixtures/chart-legends.tsx index c733130c..d77ffdd9 100644 --- a/tests/public-api/fixtures/chart-legends.tsx +++ b/tests/public-api/fixtures/chart-legends.tsx @@ -8,6 +8,9 @@ import { LineChart, AreaChart, PieChart, + FunnelChart, + RetentionChart, + JourneyChart, ComposedChart, VisualizationWidget, injectDataAppStyles, @@ -197,7 +200,7 @@ createRoot(document.getElementById('root')!).render( {serviceSeries.map(series => ( - + {series.label} ))} @@ -253,6 +256,167 @@ createRoot(document.getElementById('root')!).render( formatValue={formatValue} /> + + + + + + + + + + );