803 lines · 4 files · 19.5 kB
cases/57-scatter-marginal-histograms/tanstack.ts208 lines · entry
cases/57-scatter-marginal-histograms/tanstack.ts
import { penguins } from '@charts-poc/demo-data/penguins'
import {
binX,
binY,
colorLegend,
defineChart,
dot,
rect,
} from '@tanstack/charts'
import { viewGrid } from '@tanstack/charts/view'
import { scaleLinear } from 'd3-scale'
import { tanstackCase, tanstackMount } from '../../shared/mount'
import { samplePreviewData } from '../../shared/preview'
import type { PenguinsRow } from '@charts-poc/demo-data/penguins'
import type { ConformanceInput } from '../../types'
export type CompletePenguin = PenguinsRow & {
readonly flipper_length_mm: number
readonly body_mass_g: number
}
export const flipperBoundaries = [170, 180, 190, 200, 210, 220, 230, 240]
export const massBoundaries = [
2500, 3000, 3500, 4000, 4500, 5000, 5500, 6000, 6500,
]
const colors = ['#2563eb', '#ea580c', '#059669']
function scatterRows(input: ConformanceInput) {
return penguins
.filter((row): row is CompletePenguin => {
return row.flipper_length_mm !== null && row.body_mass_g !== null
})
.slice(input.revision * 8, input.revision * 8 + 320)
}
function scatterMarginalChart(
rows: readonly CompletePenguin[],
scatter: readonly CompletePenguin[],
showLegend: boolean,
) {
const xBins = binX(rows, {
value: 'flipper_length_mm',
thresholds: flipperBoundaries,
outputs: { count: { reduce: 'count' } },
})
const yBins = binY(rows, {
value: 'body_mass_g',
thresholds: massBoundaries,
outputs: { count: { reduce: 'count' } },
})
const flipperScale = scaleLinear().domain([
flipperBoundaries[0]!,
flipperBoundaries.at(-1)!,
])
const massScale = scaleLinear().domain([
massBoundaries[0]!,
massBoundaries.at(-1)!,
])
return viewGrid({
id: 'penguin-marginals',
rows: [
{ id: 'top', size: 82 },
{ id: 'main', grow: 1 },
],
columns: [
{ id: 'main', grow: 1 },
{ id: 'right', size: 82 },
],
gap: 8,
views: [
{
id: 'main',
row: 'main',
column: 'main',
chart: defineChart({
marks: [
dot(scatter, {
id: 'penguins',
x: 'flipper_length_mm',
y: 'body_mass_g',
color: 'species',
key: (row) =>
JSON.stringify([
row.species,
row.island,
row.culmen_length_mm,
row.culmen_depth_mm,
row.flipper_length_mm,
row.body_mass_g,
row.sex,
]),
r: 3,
fillOpacity: 0.78,
}),
],
x: {
scale: flipperScale,
grid: true,
axis: { label: 'Flipper length (mm)' },
},
y: {
scale: massScale,
grid: true,
axis: { label: 'Body mass (g)' },
},
color: {
range: colors,
...(showLegend
? { legend: colorLegend({ label: 'Species' }) }
: {}),
},
}),
},
{
id: 'top',
row: 'top',
column: 'main',
share: { x: 'main' },
chart: defineChart({
marks: [
rect(xBins, {
id: 'flipper-histogram',
x: 'x',
x1: 'x1',
x2: 'x2',
y: 'count',
y1: () => 0,
y2: 'count',
fill: '#0ea5e9',
fillOpacity: 0.78,
inset: 1,
}),
],
x: { scale: flipperScale },
y: { scale: scaleLinear },
guides: false,
}),
},
{
id: 'right',
row: 'main',
column: 'right',
share: { y: 'main' },
chart: defineChart({
marks: [
rect(yBins, {
id: 'mass-histogram',
x: 'count',
x1: () => 0,
x2: 'count',
y: 'y',
y1: 'y1',
y2: 'y2',
fill: '#f97316',
fillOpacity: 0.78,
inset: 1,
}),
],
x: { scale: scaleLinear },
y: { scale: massScale },
guides: false,
}),
},
],
})
}
export const scatterMarginalDefinition = (input: ConformanceInput) => {
const rows = scatterRows(input)
return scatterMarginalChart(rows, rows, true)
}
const catalogScatterMarginalDefinition = (input: ConformanceInput) => {
const rows = scatterRows(input)
return scatterMarginalChart(
rows,
samplePreviewData(rows, input, 80, [
(row) => row.flipper_length_mm,
(row) => row.body_mass_g,
]),
input.preview !== true,
)
}
export const mount = tanstackMount(
scatterMarginalDefinition,
'Scatterplot with marginal histograms',
{
format: (point) => {
const datum = point.datum
if ('source' in datum && 'x1' in datum) {
return `Flipper length: ${datum.x1}–${datum.x2} mm · ${datum.count} penguins`
}
if ('source' in datum && 'y1' in datum) {
return `Body mass: ${datum.y1}–${datum.y2} g · ${datum.count} penguins`
}
return `${datum.species} · ${datum.flipper_length_mm} mm · ${datum.body_mass_g} g`
},
},
)
export const catalogCase = tanstackCase(
catalogScatterMarginalDefinition,
mount.ariaLabel,
mount.interactiveTooltip,
)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)
}shared/preview.ts77 lines · dependency
shared/preview.ts
import type { ConformanceInput } from '../types'
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))
}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
}