722 lines · 4 files · 17.3 kB
cases/35-grouped-tooltip/selection.ts23 lines · dependency
cases/35-grouped-tooltip/selection.ts
import type { IndustriesRow } from '@charts-poc/demo-data/industries'
export const industryNames = ['Manufacturing', 'Construction', 'Finance']
export type IndustryName = (typeof industryNames)[number]
export type GroupedTooltipDatum = IndustriesRow & {
readonly industry: IndustryName
}
export function selectGroupedTooltipData(
rows: readonly IndustriesRow[],
revision = 0,
): readonly GroupedTooltipDatum[] {
const offset = Math.abs(revision) % 2
const start = Date.UTC(2000, offset)
const end = Date.UTC(2003, offset)
return rows.filter(
(row): row is GroupedTooltipDatum =>
row.date.getTime() >= start &&
row.date.getTime() < end &&
industryNames.some((industry) => industry === row.industry),
)
}cases/35-grouped-tooltip/tanstack.ts181 lines · entry
cases/35-grouped-tooltip/tanstack.ts
import {
bandX,
defineChart,
dot,
lineY,
mountChart,
whenFocused,
} from '@tanstack/charts'
import { decorative } from '@tanstack/charts/mark/decorative'
import type { ChartHostOptions, ChartTooltipOptions } from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import { portal } from '@tanstack/charts/tooltip/portal'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { industries } from '@charts-poc/demo-data/industries'
import type {
ConformanceHandle,
ConformanceInput,
ConformanceTestDriver,
} from '../../types'
import { industryNames, selectGroupedTooltipData } from './selection'
import type { GroupedTooltipDatum } from './selection'
import { tanstackCase } from '../../shared/mount'
const colors = ['#2563eb', '#f97316', '#10b981']
const definition = (input: ConformanceInput) => {
const rows = selectGroupedTooltipData(industries, input.revision)
const dates = rows.filter((row) => row.industry === industryNames[0])
return defineChart({
marks: [
whenFocused(
bandX(dates, {
x: 'date',
fill: '#64748b',
fillOpacity: 0.14,
inset: 3,
radius: 4,
}),
{ match: 'x' },
),
decorative(
lineY(rows, {
x: 'date',
y: 'unemployed',
color: 'industry',
}),
),
dot(rows, {
x: 'date',
y: 'unemployed',
z: 'industry',
color: 'industry',
r: 2.5,
states: [
{
when: { focus: 'group' },
style: { r: 5, stroke: 'Canvas', strokeWidth: 1.5 },
transition: {
type: 'tween',
duration: 140,
easing: 'ease-out',
},
},
{
when: { focus: 'unmatched' },
style: { opacity: 0.3 },
},
],
}),
],
x: { scale: scaleUtc },
y: {
scale: scaleLinear,
grid: true,
axis: { label: 'Unemployed (thousands)' },
},
color: {
domain: industryNames,
range: colors,
},
})
}
const interactiveTooltip: ChartTooltipOptions<GroupedTooltipDatum> = {
portal,
anchor: 'group-center',
placement: ['top', 'right', 'left', 'bottom'],
sort: 'color-domain',
}
const catalogDefinition = (input: ConformanceInput) =>
defineChart(definition(input), { focus: 'group-x' })
export const catalogCase = tanstackCase(
catalogDefinition,
'Grouped industry unemployment tooltip',
interactiveTooltip,
)
const configuredDefinition = (input: ConformanceInput) =>
defineChart(definition(input), {
svgAnimation: false,
keyboard: true,
focus: 'group-x',
tooltip: {
use: tooltip,
...interactiveTooltip,
},
})
export function mount(
container: HTMLElement,
input: ConformanceInput,
): ConformanceHandle {
let focusedRows: string[] = []
const options: ChartHostOptions<GroupedTooltipDatum> = {
definition: configuredDefinition(input),
width: input.width,
height: input.height,
ariaLabel: 'Grouped industry unemployment tooltip',
onFocusGroupChange(points) {
focusedRows = points.map(
(point) => `${dateKey(point.datum.date)}:${point.datum.industry}`,
)
},
}
const host = mountChart(container, options)
const driver: ConformanceTestDriver = {
resolveTarget(target) {
if (target.view && target.view !== 'main') return null
const date = target.anchor.startsWith('date:')
? target.anchor.slice('date:'.length)
: null
const point = host
.getScene()
.points.find(
(candidate) =>
dateKey(candidate.datum.date) === date &&
candidate.datum.industry === industryNames[0],
)
const svg = container.querySelector('svg')
if (!point || !svg) return null
const scene = host.getScene()
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (point.x / scene.width) * bounds.width,
y: bounds.top + (point.y / scene.height) * bounds.height,
focusElement: svg,
}
},
readState() {
const tooltip = container.querySelector<HTMLElement>('.ts-chart-tooltip')
return {
focus: { rows: [...focusedRows] },
tooltip: {
visible: Boolean(tooltip && !tooltip.hidden),
text: tooltip?.textContent?.trim() ?? '',
},
}
},
}
return {
driver,
update(nextInput) {
host.update({
...options,
definition: configuredDefinition(nextInput),
width: nextInput.width,
height: nextInput.height,
})
},
destroy() {
host.destroy()
},
}
}
function dateKey(date: Date) {
return date.toISOString().slice(0, 10)
}shared/mount.ts144 lines · dependency
shared/mount.ts
import {
defineChart,
isResponsiveChartDefinition,
mountChart,
} from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import type {
DomChartDefinition,
ChartDefinitionOptions,
ChartValue,
ChartTooltipOptions,
} from '@tanstack/charts'
import type {
ConformanceHandle,
ConformanceInput,
ConformanceMount,
} from '../types'
export function mountObservablePlot(
container: HTMLElement,
input: ConformanceInput,
render: (input: ConformanceInput) => HTMLElement | SVGSVGElement,
): ConformanceHandle {
let element = render(input)
container.append(element)
return {
update(nextInput) {
const nextElement = render(nextInput)
element.replaceWith(nextElement)
element = nextElement
},
destroy() {
element.remove()
},
}
}
export function tanstackMount<
TDatum,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
>(
createDefinition: (
input: ConformanceInput,
) => DomChartDefinition<TDatum, TXValue, TYValue>,
ariaLabel: string,
interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
const mount: ConformanceMount = (container, input) => {
const options = {
definition: withConformanceBehavior(
createDefinition(input),
input,
interactiveTooltip,
),
width: input.width,
height: input.height,
ariaLabel,
} as const
const host = mountChart(container, options)
return {
update(nextInput) {
host.update({
...options,
definition: withConformanceBehavior(
createDefinition(nextInput),
nextInput,
interactiveTooltip,
),
width: nextInput.width,
height: nextInput.height,
})
},
destroy() {
host.destroy()
},
}
}
const catalogCase = Object.assign(mount, {
createDefinition,
ariaLabel,
interactiveTooltip,
})
return Object.assign(catalogCase, { mount: catalogCase })
}
export interface TanStackConformanceCase<
TDatum,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
> {
(container: HTMLElement, input: ConformanceInput): ConformanceHandle
createDefinition: (
input: ConformanceInput,
) => DomChartDefinition<TDatum, TXValue, TYValue>
ariaLabel: string
interactiveTooltip: true | ChartTooltipOptions<TDatum>
mount: ConformanceMount
}
export function tanstackCase<
TDatum,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
>(
createDefinition: (
input: ConformanceInput,
) => DomChartDefinition<TDatum, TXValue, TYValue>,
ariaLabel: string,
interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
return tanstackMount(createDefinition, ariaLabel, interactiveTooltip)
}
export function withConformanceBehavior<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
definition: DomChartDefinition<TDatum, TXValue, TYValue>,
input: ConformanceInput,
interactiveTooltip: true | ChartTooltipOptions<TDatum>,
): DomChartDefinition<TDatum, TXValue, TYValue> {
const behavior: ChartDefinitionOptions<TDatum, TXValue, TYValue, 'dom'> = {
svgAnimation: false,
...(input.interactive === true ? {} : { focus: false }),
keyboard: input.interactive === true,
tooltip:
input.interactive !== true
? false
: interactiveTooltip === true
? tooltip
: { use: tooltip, ...interactiveTooltip },
}
if (isResponsiveChartDefinition(definition)) {
return defineChart(definition, behavior)
}
return defineChart(definition, behavior)
}types.ts374 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
}