843 lines · 6 files · 21.9 kB
cases/81-recharts-interactive-legend/model.ts70 lines · dependency
cases/81-recharts-interactive-legend/model.ts
import type { IndustriesRow } from '@charts-poc/demo-data/industries'
export const legendSeries = [
{ id: 'Manufacturing', label: 'Manufacturing' },
{ id: 'Construction', label: 'Construction' },
] as const
export type LegendSeriesId = (typeof legendSeries)[number]['id']
export interface WideLegendRow {
date: Date
Manufacturing: number
Construction: number
}
export function isLegendSeriesId(value: unknown): value is LegendSeriesId {
return value === 'Manufacturing' || value === 'Construction'
}
export function toggleLegendSeries(
visibleSeries: readonly LegendSeriesId[],
seriesId: LegendSeriesId,
): readonly LegendSeriesId[] {
const visible = visibleSeries.includes(seriesId)
return legendSeries
.map((series) => series.id)
.filter((id) => (id === seriesId ? !visible : visibleSeries.includes(id)))
}
export function legendRows(
rows: readonly IndustriesRow[],
revision = 0,
): readonly IndustriesRow[] {
const firstMonth = revision % 2 === 0 ? 0 : 6
const lastMonth = firstMonth + 5
return rows.filter(
(row) =>
row.date.getUTCFullYear() === 2000 &&
row.date.getUTCMonth() >= firstMonth &&
row.date.getUTCMonth() <= lastMonth &&
isLegendSeriesId(row.industry),
)
}
// Recharts requires one wide row per x value for multiple line series.
export function wideLegendRows(
rows: readonly IndustriesRow[],
): readonly WideLegendRow[] {
const grouped = new Map<number, Partial<WideLegendRow>>()
for (const row of rows) {
if (!isLegendSeriesId(row.industry)) continue
const timestamp = row.date.getTime()
const current = grouped.get(timestamp) ?? { date: row.date }
current[row.industry] = row.unemployed
grouped.set(timestamp, current)
}
return [...grouped.values()].flatMap((row) =>
row.date &&
row.Manufacturing !== undefined &&
row.Construction !== undefined
? [
{
date: row.date,
Manufacturing: row.Manufacturing,
Construction: row.Construction,
},
]
: [],
)
}cases/81-recharts-interactive-legend/tanstack.ts1 line · entry
cases/81-recharts-interactive-legend/tanstack.ts
export { interactiveLegendDefinition, mount } from './view'cases/81-recharts-interactive-legend/view.tsx195 lines · dependency
cases/81-recharts-interactive-legend/view.tsx
import {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { colorLegend, defineChart, lineY } from '@tanstack/charts'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { interactiveColorLegend } from '@tanstack/charts/legend'
import { Chart } from '@tanstack/charts/react'
import { industries } from '@charts-poc/demo-data/industries'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { catalogPreviewDefinition } from '../../shared/preview'
import { reactMount } from '../../shared/react-mount'
import { isLegendSeriesId, legendRows, legendSeries } from './model'
import type { ConformanceTarget, ConformanceTestDriver } from '../../types'
import type { ReactConformanceProps } from '../../shared/react-mount'
import type { LegendSeriesId } from './model'
const yDomain = [0, 900] as const
const initialVisibleSeries: readonly LegendSeriesId[] = [
'Manufacturing',
'Construction',
]
const seriesColors: Readonly<Record<LegendSeriesId, string>> = {
Manufacturing: '#2563eb',
Construction: '#f97316',
}
export function interactiveLegendDefinition(
revision: number,
visibleSeries: readonly LegendSeriesId[],
onVisibleSeriesChange: (visible: readonly LegendSeriesId[]) => void,
preview = false,
) {
const rows = legendRows(industries, revision)
return defineChart(
defineChart({
marks: [
lineY(rows, {
id: 'industry-lines',
x: 'date',
y: 'unemployed',
color: 'industry',
strokeWidth: 2.5,
}),
],
x: {
scale: scaleUtc,
axis: {
ticks: {
format: (date) =>
date.toLocaleDateString('en-US', {
month: 'short',
timeZone: 'UTC',
}),
},
},
},
y: {
scale: scaleLinear().domain(yDomain),
grid: true,
axis: { ticks: { count: 5 }, label: 'Unemployed (thousands)' },
},
color: {
domain: legendSeries.map((series) => series.id),
range: legendSeries.map((series) => seriesColors[series.id]),
legend: preview
? colorLegend({ label: 'Series', placement: 'bottom' })
: interactiveColorLegend({
visible: controlledSignal(visibleSeries, onVisibleSeriesChange),
placement: 'bottom',
ariaLabel: 'Series visibility',
}),
},
margin: preview
? { top: 0, right: 0, left: 0 }
: { top: 20, right: 24, left: 62 },
}),
{ svgAnimation: false, keyboard: false },
)
}
const InteractiveLegendExample = forwardRef<
ConformanceTestDriver,
ReactConformanceProps
>(function InteractiveLegendExample({ input, idPrefix }, ref) {
const viewRef = useRef<HTMLDivElement>(null)
const [visibleSeries, setVisibleSeries] = useState(initialVisibleSeries)
const definition = useMemo(
() =>
interactiveLegendDefinition(
input.revision,
visibleSeries,
setVisibleSeries,
input.preview,
),
[input.preview, input.revision, visibleSeries],
)
useImperativeHandle(
ref,
() => ({
resolveTarget(target) {
const seriesId = seriesFromTarget(target)
if (!seriesId) return null
const button = viewRef.current?.querySelector<HTMLElement>(
`[data-series-id="${seriesId}"]`,
)
return button ? center(button) : null
},
readState() {
const chartSurface = viewRef.current
const activeElement = viewRef.current?.ownerDocument.activeElement
return {
visibleSeries,
hiddenSeries: legendSeries
.map((series) => series.id)
.filter((seriesId) => !visibleSeries.includes(seriesId)),
renderedSeries: chartSurface ? renderedSeries(chartSurface) : [],
yDomain,
focusedSeries:
activeElement instanceof HTMLElement
? (activeElement.dataset.seriesId ?? null)
: null,
}
},
}),
[visibleSeries],
)
if (input.preview) {
return (
<Chart
idPrefix={idPrefix}
definition={catalogPreviewDefinition(definition, {
legend: true,
margin: true,
})}
initialWidth={input.width}
aspectRatio={input.width / input.height}
ariaLabel="Manufacturing and construction unemployment chart"
/>
)
}
return (
<div
ref={viewRef}
data-conformance-view="main"
role="region"
aria-label="Interactive unemployment series"
style={{
width: input.width,
height: input.height,
}}
>
<Chart
idPrefix={idPrefix}
definition={definition}
width={input.width}
height={input.height}
ariaLabel="Manufacturing and construction unemployment chart"
/>
</div>
)
})
export const catalogComponent = InteractiveLegendExample
export const mount = reactMount(InteractiveLegendExample)
function center(element: HTMLElement | SVGElement) {
const bounds = element.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: element,
}
}
function seriesFromTarget(target: ConformanceTarget) {
if (target.view !== undefined && target.view !== 'main') return null
const [kind, id] = target.anchor.split(':')
return kind === 'legend' && isLegendSeriesId(id) ? id : null
}
function renderedSeries(surface: HTMLElement) {
const strokes = [
...surface.querySelectorAll<SVGPathElement>('.ts-chart__line path'),
].map((path) => path.getAttribute('stroke')?.toLowerCase())
return legendSeries
.filter((series) => strokes.includes(seriesColors[series.id].toLowerCase()))
.map((series) => series.id)
}shared/preview.ts144 lines · dependency
shared/preview.ts
import { isResponsiveChartDefinition } from '@tanstack/charts'
import type {
ChartPoint,
ChartScene,
ChartValue,
DomChartDefinition,
} from '@tanstack/charts'
import type { ConformanceInput } from '../types'
export interface CatalogPreviewOptions<
TDatum = unknown,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
> {
/** Keep the source definition's Cartesian axes and grid. */
guides?: boolean
/** Keep the source definition's color legend. */
legend?: boolean
/** Keep the source definition's authored or automatic margins. */
margin?: boolean
/** Paint one deterministic source point through the chart's focus strategy. */
focus?: (
scene: ChartScene<TDatum, TXValue, TYValue>,
input: ConformanceInput,
) => ChartPoint<TDatum, TXValue, TYValue> | null
}
export function catalogPreviewDefinition<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
definition: DomChartDefinition<TDatum, TXValue, TYValue>,
options: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
if (isResponsiveChartDefinition(definition)) {
return {
...definition,
chart(context) {
const spec = definition.chart(context)
const color = previewColor(spec.color, options.legend === true)
return {
...spec,
...(options.guides === true ? {} : { guides: false }),
...(options.margin === true ? {} : { margin: 0 }),
...(color ? { color } : {}),
}
},
}
}
const color = previewColor(definition.color, options.legend === true)
return {
...definition,
...(options.guides === true ? {} : { guides: false }),
...(options.margin === true ? {} : { margin: 0 }),
...(color ? { color } : {}),
}
}
function previewColor<TColor extends { legend?: unknown }>(
color: TColor | undefined,
keepLegend: boolean,
): Omit<TColor, 'legend'> | TColor | undefined {
if (!color || keepLegend) return color
const { legend: _legend, ...withoutLegend } = color
return withoutLegend
}
export function samplePreviewData<TDatum>(
data: readonly TDatum[],
input: ConformanceInput,
limit: number,
accessors: readonly ((datum: TDatum) => number | null | undefined)[] = [],
): readonly TDatum[] {
if (input.preview !== true || data.length <= limit) return data
const selected = new Set<number>()
const slots = Math.max(2, limit - accessors.length * 2)
for (let slot = 0; slot < slots; slot += 1) {
selected.add(Math.round((slot / (slots - 1)) * (data.length - 1)))
}
for (const accessor of accessors) {
let minimumIndex = -1
let minimum = Number.POSITIVE_INFINITY
let maximumIndex = -1
let maximum = Number.NEGATIVE_INFINITY
data.forEach((datum, index) => {
const value = accessor(datum)
if (value === null || value === undefined || !Number.isFinite(value)) {
return
}
if (value < minimum) {
minimum = value
minimumIndex = index
}
if (value > maximum) {
maximum = value
maximumIndex = index
}
})
if (minimumIndex >= 0) selected.add(minimumIndex)
if (maximumIndex >= 0) selected.add(maximumIndex)
}
return data.filter((_datum, index) => selected.has(index))
}
export function samplePreviewSeries<TDatum, TSeries>(
data: readonly TDatum[],
input: ConformanceInput,
limitPerSeries: number,
series: (datum: TDatum) => TSeries,
): readonly TDatum[] {
if (input.preview !== true) return data
const indicesBySeries = new Map<TSeries, number[]>()
data.forEach((datum, index) => {
const key = series(datum)
const indices = indicesBySeries.get(key) ?? []
indices.push(index)
indicesBySeries.set(key, indices)
})
const selected = new Set<number>()
for (const indices of indicesBySeries.values()) {
if (indices.length <= limitPerSeries) {
indices.forEach((index) => selected.add(index))
continue
}
for (let slot = 0; slot < limitPerSeries; slot += 1) {
const index =
indices[
Math.round((slot / (limitPerSeries - 1)) * (indices.length - 1))
]
if (index !== undefined) selected.add(index)
}
}
return data.filter((_datum, index) => selected.has(index))
}shared/react-mount.ts57 lines · dependency
shared/react-mount.ts
import { createElement } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
import type {
ConformanceInput,
ConformanceMount,
ConformanceTestDriver,
} from '../types'
export interface ReactConformanceProps {
input: ConformanceInput
idPrefix?: string
}
export type ReactConformanceComponent = ForwardRefExoticComponent<
ReactConformanceProps & RefAttributes<ConformanceTestDriver>
>
export function reactMount(
Component: ReactConformanceComponent,
): ConformanceMount {
return (container, input) => {
const root = createRoot(container)
let activeDriver: ConformanceTestDriver | null = null
const driver = new Proxy({} as ConformanceTestDriver, {
get(_target, property) {
const value = activeDriver?.[property as keyof ConformanceTestDriver]
return typeof value === 'function' ? value.bind(activeDriver) : value
},
})
const render = (nextInput: ConformanceInput) => {
flushSync(() => {
root.render(
createElement(Component, {
input: nextInput,
ref: (nextDriver: ConformanceTestDriver | null) => {
activeDriver = nextDriver
},
}),
)
})
}
render(input)
return {
update: render,
driver,
destroy() {
flushSync(() => {
root.unmount()
})
},
}
}
}types.ts376 lines · dependency
types.ts
export type ConformanceReferenceRenderer =
'observable-plot' | 'recharts' | 'echarts'
export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack'
export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred'
export type ConformanceGeometryRole =
| 'arc'
| 'area'
| 'arrow'
| 'bar'
| 'cell'
| 'contour'
| 'delaunay'
| 'density'
| 'dot'
| 'frame'
| 'geo'
| 'hexagon'
| 'line'
| 'link'
| 'rect'
| 'radar'
| 'regression'
| 'rule'
| 'text'
| 'tick'
| 'vector'
| 'voronoi'
| 'waffle'
export interface ConformanceInput {
width: number
height: number
revision: number
interactive?: boolean
/** Use lower-detail geometry suited to compact catalog cards. */
preview?: boolean
/** True only for semantic browser scenarios, not catalog or visual mounts. */
behavior?: boolean
}
export interface ConformanceHandle {
update: (input: ConformanceInput) => void
driver?: ConformanceTestDriver
destroy: () => void
}
export type ConformanceMount = (
container: HTMLElement,
input: ConformanceInput,
) => ConformanceHandle
export interface ConformanceGeometryExpectation {
id?: string
view?: string
role: ConformanceGeometryRole
count: number
maxCount?: number
rendererRoles?: Partial<Record<ConformanceRenderer, ConformanceGeometryRole>>
}
export type ConformanceAxis = 'x' | 'y' | 'fx' | 'fy'
export interface ConformanceGuideExpectation {
id: string
axis:
| ConformanceAxis
| (Record<'tanstack', ConformanceAxis> &
Partial<Record<ConformanceReferenceRenderer, ConformanceAxis>>)
sequence?: readonly string[]
maxRepeat?: number
}
export type ConformanceJsonValue =
| null
| boolean
| number
| string
| readonly ConformanceJsonValue[]
| ConformanceJsonObject
export interface ConformanceJsonObject {
readonly [key: string]: ConformanceJsonValue
}
export interface ConformanceTarget {
view?: string
anchor: string
}
export type ConformanceRenderedTarget =
| {
selector: string
index?: number
role?: never
name?: never
exact?: never
root?: never
page?: never
}
| {
role: string
name?: string
exact?: boolean
index?: number
selector?: never
root?: never
page?: never
}
| {
root: true
selector?: never
role?: never
name?: never
exact?: never
index?: never
page?: never
}
| {
page: true
selector?: never
role?: never
name?: never
exact?: never
index?: never
root?: never
}
export interface ConformanceResolvedTarget {
/** Viewport-relative client coordinate used by Playwright mouse input. */
x: number
/** Viewport-relative client coordinate used by Playwright mouse input. */
y: number
/** Optional element to focus before a real Playwright keyboard action. */
focusElement?: HTMLElement | SVGElement
}
export interface ConformanceGeometryQuery {
view?: string
role: ConformanceGeometryRole
}
export interface ConformanceGeometrySample {
/** Viewport-relative client box, matching getBoundingClientRect coordinates. */
x: number
y: number
width: number
height: number
paint?: string
}
export interface ConformanceTestDriver {
/**
* Benchmark-only semantic bridge. Case metadata names anchors; each renderer
* resolves those anchors without exposing renderer-specific selectors.
*/
resolveTarget: (target: ConformanceTarget) => ConformanceResolvedTarget | null
readState: () => ConformanceJsonObject
geometry?: (
query: ConformanceGeometryQuery,
) => readonly ConformanceGeometrySample[]
/**
* Viewport-relative logical view bounds. Multi-grid renderers may expose
* independent views without separate DOM roots.
*/
viewBounds?: (view?: string) => ConformanceGeometrySample | null
settle?: () => void | Promise<void>
}
export type ConformanceStateAssertion =
| {
path: string
equals: ConformanceJsonValue
}
| {
path: string
includes: ConformanceJsonValue
}
| {
path: string
approx: number
tolerance: number
}
type ConformanceRenderedStringMatcher =
| {
equals: string | null
includes?: never
}
| {
includes: string
equals?: never
}
type ConformanceRenderedNumberMatcher =
| {
equals: number
approx?: never
tolerance?: never
atLeast?: never
atMost?: never
}
| {
approx: number
tolerance: number
equals?: never
atLeast?: never
atMost?: never
}
| {
atLeast: number
equals?: never
approx?: never
tolerance?: never
atMost?: never
}
| {
atMost: number
equals?: never
approx?: never
tolerance?: never
atLeast?: never
}
export type ConformanceRenderedAssertion =
| ({
target: ConformanceRenderedTarget
property: 'count'
} & ConformanceRenderedNumberMatcher)
| ({
target: ConformanceRenderedTarget
property: 'text'
} & ConformanceRenderedStringMatcher)
| ({
target: ConformanceRenderedTarget
property: 'attribute'
attribute: string
} & ConformanceRenderedStringMatcher)
| {
target: ConformanceRenderedTarget
property: 'visible' | 'focused'
equals: boolean
}
| ({
target: ConformanceRenderedTarget
property:
| 'scrollLeft'
| 'scrollTop'
| 'scrollWidth'
| 'scrollHeight'
| 'clientWidth'
| 'clientHeight'
| 'width'
| 'height'
} & ConformanceRenderedNumberMatcher)
| {
target: ConformanceRenderedTarget
property: 'contained'
within?: ConformanceRenderedTarget
tolerance?: number
equals: true
}
export type ConformanceInteractionStep =
| {
type: 'pointerMove'
target: ConformanceTarget
steps?: number
}
| {
type: 'pointerDown'
target: ConformanceTarget
}
| {
type: 'pointerUp'
target: ConformanceTarget
}
| {
type: 'pointerCancel'
}
| {
type: 'pointerLeave'
view?: string
}
| {
type: 'update'
revision: number
}
| {
type: 'click'
target: ConformanceTarget
}
| {
type: 'key'
key: string
target?: ConformanceTarget
}
| {
type: 'drag'
from: ConformanceTarget
to: ConformanceTarget
steps?: number
}
| {
type: 'wheel'
target: ConformanceTarget
deltaX?: number
deltaY?: number
steps?: number
deltaMode?: 'pixel' | 'line' | 'page'
}
| {
type: 'touchTap'
target: ConformanceTarget
}
| {
type: 'touchDrag'
from: ConformanceTarget
to: ConformanceTarget
steps?: number
cancel?: boolean
}
| {
type: 'wait'
durationMs: number
}
| {
type: 'assert'
assertions: readonly ConformanceStateAssertion[]
}
| {
type: 'assertRendered'
assertions: readonly ConformanceRenderedAssertion[]
}
| {
type: 'screenshot'
name: string
view?: string
}
export interface ConformanceInteractionScenario {
id: string
steps: readonly ConformanceInteractionStep[]
}
export interface ConformanceCaseMeta {
schemaVersion: 1
referenceRenderer?: ConformanceReferenceRenderer
order: number
id: string
title: string
family: string
intent: string
support: ConformanceSupport
features: readonly string[]
geometry: readonly ConformanceGeometryExpectation[]
minimumGeometrySimilarity?: number
guideAssertions?: readonly ConformanceGuideExpectation[]
interactionScenarios?: readonly ConformanceInteractionScenario[]
source: {
title: string
url: string
}
ai: {
create: string
maintain: string
}
}
export interface ConformanceImplementationModule {
mount: ConformanceMount
/** Definition-only mount used by compact generated catalog previews. */
catalogCase?: { mount: ConformanceMount }
}