1,135 lines · 7 files · 29.8 kB
cases/108-country-choropleth/tanstack.ts75 lines · entry
cases/108-country-choropleth/tanstack.ts
import { defineChart } from '@tanstack/charts'
import { geoShape } from '@tanstack/charts/geo'
import { geoEqualEarth } from 'd3-geo'
import { scaleQuantize } from 'd3-scale'
import {
previewWorldLand,
worldLand,
worldSphere,
} from '../../shared/fixtures/country-atlas'
import {
learningPovertyCountries,
previewLearningPovertyCountries,
} from '../../shared/transforms/learning-poverty'
import { tanstackMount } from '../../shared/mount'
import type { ConformanceInput } from '../../types'
const colorRanges = [
['#ecfeff', '#a5f3fc', '#67e8f9', '#06b6d4', '#0e7490', '#164e63'],
['#f0fdf4', '#bbf7d0', '#86efac', '#22c55e', '#15803d', '#14532d'],
]
const projection = {
type: geoEqualEarth,
fit: 'sphere' as const,
}
const previewProjection = {
type: () => geoEqualEarth().precision(2),
fit: 'sphere' as const,
}
const definition = (input: ConformanceInput) =>
defineChart({
marks: [
geoShape([input.preview ? previewWorldLand : worldLand], {
projection: input.preview ? previewProjection : projection,
fill: '#e2e8f0',
stroke: '#ffffff',
strokeWidth: 0.55,
}),
geoShape(
input.preview
? previewLearningPovertyCountries
: learningPovertyCountries,
{
projection: input.preview ? previewProjection : projection,
color: (country) => country.properties.density,
stroke: 'currentColor',
strokeOpacity: 0.34,
strokeWidth: 0.55,
},
),
geoShape([worldSphere], {
projection: input.preview ? previewProjection : projection,
fill: 'none',
stroke: 'currentColor',
strokeOpacity: 0.35,
strokeWidth: 0.75,
}),
],
color: {
scale: scaleQuantize<string>,
range: colorRanges[input.revision % 2] ?? colorRanges[0],
},
margin: 12,
})
export const mount = tanstackMount(
definition,
'World population-density choropleth',
{
format: ({ datum }) =>
'properties' in datum && 'density' in datum.properties
? `${datum.properties['Country Name']} · ${datum.properties.density} people/km²`
: 'World land',
},
)shared/fixtures/country-atlas.ts135 lines · dependency
shared/fixtures/country-atlas.ts
import countriesAtlasJson from 'world-atlas/countries-110m.json'
import landAtlasJson from 'world-atlas/land-110m.json'
import detailedLandAtlasJson from 'world-atlas/land-50m.json'
import { geoGraticule, geoGraticule10 } from 'd3-geo'
import { feature } from 'topojson-client'
import { simplifyPolygonGeometry } from './simplify-geo'
import type {
ExtendedFeature,
ExtendedFeatureCollection,
GeoGeometryObjects,
GeoSphere,
} from 'd3-geo'
type AtlasTopology = Parameters<typeof feature>[0]
export type CountryGeometry = Extract<
GeoGeometryObjects,
{ type: 'Polygon' | 'MultiPolygon' }
>
export interface CountryProperties {
name: string
}
export type CountryFeature = ExtendedFeature<CountryGeometry, CountryProperties>
export type LandFeature = ExtendedFeature<CountryGeometry, Record<never, never>>
export const worldSphere: GeoSphere = { type: 'Sphere' }
export const worldGraticule = geoGraticule10()
export const previewWorldGraticule = geoGraticule().step([30, 30])()
const countriesTopology = atlasTopology(
countriesAtlasJson,
'world-atlas countries-110m',
)
const countriesObject = countriesTopology.objects.countries
if (!countriesObject) {
throw new TypeError('world-atlas countries-110m is missing countries')
}
const convertedCountries = feature(countriesTopology, countriesObject)
if (convertedCountries.type !== 'FeatureCollection') {
throw new TypeError('world-atlas countries did not produce a collection')
}
export const worldCountries: readonly CountryFeature[] =
convertedCountries.features.flatMap<CountryFeature>((entry) => {
if (
!isCountryGeometry(entry.geometry) ||
!isRecord(entry.properties) ||
typeof entry.properties.name !== 'string'
) {
return []
}
return [
{
type: 'Feature',
id: entry.id === undefined ? entry.properties.name : String(entry.id),
geometry: entry.geometry,
properties: {
name: entry.properties.name,
},
},
]
})
if (worldCountries.length !== 177) {
throw new TypeError(
`Expected 177 world-atlas countries, got ${worldCountries.length}`,
)
}
export const worldCountryCollection: ExtendedFeatureCollection<CountryFeature> =
{
type: 'FeatureCollection',
features: [...worldCountries],
}
export const worldLand = convertLand(landAtlasJson, 'world-atlas land-110m')
export const previewWorldLand: LandFeature = {
...worldLand,
geometry: simplifyPolygonGeometry(worldLand.geometry, 2),
}
export const detailedWorldLand = convertLand(
detailedLandAtlasJson,
'world-atlas land-50m',
)
function atlasTopology(value: unknown, label: string): AtlasTopology {
if (!isAtlasTopology(value)) {
throw new TypeError(`${label} is not valid TopoJSON`)
}
return value
}
function convertLand(value: unknown, label: string): LandFeature {
const topology = atlasTopology(value, label)
const landObject = topology.objects.land
if (!landObject) {
throw new TypeError(`${label} is missing land`)
}
const converted = feature(topology, landObject)
const land =
converted.type === 'FeatureCollection' ? converted.features[0] : converted
if (!land || land.type !== 'Feature' || !isCountryGeometry(land.geometry)) {
throw new TypeError(`${label} did not produce polygon geometry`)
}
return {
type: 'Feature',
geometry: land.geometry,
properties: {},
}
}
function isCountryGeometry(
geometry: GeoGeometryObjects,
): geometry is CountryGeometry {
return geometry.type === 'Polygon' || geometry.type === 'MultiPolygon'
}
function isAtlasTopology(value: unknown): value is AtlasTopology {
return (
isRecord(value) &&
value.type === 'Topology' &&
Array.isArray(value.arcs) &&
isRecord(value.objects)
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}shared/fixtures/simplify-geo.ts130 lines · dependency
shared/fixtures/simplify-geo.ts
import type { GeoGeometryObjects } from 'd3-geo'
type PolygonGeometry = Extract<
GeoGeometryObjects,
{ type: 'Polygon' | 'MultiPolygon' }
>
type Position = number[]
export function simplifyPolygonGeometry(
geometry: PolygonGeometry,
tolerance: number,
): PolygonGeometry {
if (geometry.type === 'Polygon') {
return {
type: 'Polygon',
coordinates: geometry.coordinates.map((ring) =>
simplifyRing(ring, tolerance),
),
}
}
return {
type: 'MultiPolygon',
coordinates: geometry.coordinates.map((polygon) =>
polygon.map((ring) => simplifyRing(ring, tolerance)),
),
}
}
function simplifyRing(
ring: readonly Position[],
tolerance: number,
): Position[] {
if (ring.length <= 4) return [...ring]
const openRing = ring.slice(0, -1)
const anchor = openRing[0]
if (!anchor) return [...ring]
let splitIndex = 1
let farthestDistance = 0
for (let index = 1; index < openRing.length; index += 1) {
const point = openRing[index]
if (!point) continue
const distance = squaredDistance(anchor, point)
if (distance > farthestDistance) {
farthestDistance = distance
splitIndex = index
}
}
const firstHalf = simplifyLine(
openRing.slice(0, splitIndex + 1),
tolerance * tolerance,
)
const secondHalf = simplifyLine(
[...openRing.slice(splitIndex), anchor],
tolerance * tolerance,
)
const simplified = [...firstHalf.slice(0, -1), ...secondHalf]
return simplified.length >= 4 ? simplified : [...ring]
}
function simplifyLine(
points: readonly Position[],
squaredTolerance: number,
): Position[] {
const first = points[0]
const last = points.at(-1)
if (!first || !last || points.length <= 2) return [...points]
let farthestIndex = 0
let farthestDistance = squaredTolerance
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index]
if (!point) continue
const distance = squaredSegmentDistance(point, first, last)
if (distance > farthestDistance) {
farthestDistance = distance
farthestIndex = index
}
}
if (farthestIndex === 0) return [first, last]
const left = simplifyLine(
points.slice(0, farthestIndex + 1),
squaredTolerance,
)
const right = simplifyLine(points.slice(farthestIndex), squaredTolerance)
return [...left.slice(0, -1), ...right]
}
function squaredSegmentDistance(
point: Position,
start: Position,
end: Position,
): number {
const [pointX = 0, pointY = 0] = point
let [x = 0, y = 0] = start
const [endX = 0, endY = 0] = end
let dx = endX - x
let dy = endY - y
if (dx !== 0 || dy !== 0) {
const progress =
((pointX - x) * dx + (pointY - y) * dy) / (dx * dx + dy * dy)
if (progress > 1) {
x = endX
y = endY
} else if (progress > 0) {
x += dx * progress
y += dy * progress
}
dx = pointX - x
dy = pointY - y
} else {
dx = pointX - x
dy = pointY - y
}
return dx * dx + dy * dy
}
function squaredDistance(left: Position, right: Position): number {
const dx = (left[0] ?? 0) - (right[0] ?? 0)
const dy = (left[1] ?? 0) - (right[1] ?? 0)
return dx * dx + dy * dy
}shared/mount.ts179 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'
import { catalogPreviewDefinition, type CatalogPreviewOptions } from './preview'
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,
previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
const mount: ConformanceMount = (container, input) => {
const options = {
definition: withConformanceBehavior(
createDefinition(input),
input,
interactiveTooltip,
previewOptions,
),
width: input.width,
height: input.height,
ariaLabel,
} as const
const host = mountChart(container, options)
applyCatalogPreviewFocus(host, input, previewOptions)
return {
update(nextInput) {
host.update({
...options,
definition: withConformanceBehavior(
createDefinition(nextInput),
nextInput,
interactiveTooltip,
previewOptions,
),
width: nextInput.width,
height: nextInput.height,
})
applyCatalogPreviewFocus(host, nextInput, previewOptions)
},
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,
previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
return tanstackMount(
createDefinition,
ariaLabel,
interactiveTooltip,
previewOptions,
)
}
export function withConformanceBehavior<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
definition: DomChartDefinition<TDatum, TXValue, TYValue>,
input: ConformanceInput,
interactiveTooltip: true | ChartTooltipOptions<TDatum>,
previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
const presentation =
input.preview === true
? catalogPreviewDefinition(definition, previewOptions)
: definition
const behavior: ChartDefinitionOptions<TDatum, TXValue, TYValue, 'dom'> = {
svgAnimation: false,
...(input.interactive === true ||
(input.preview === true && previewOptions.focus)
? {}
: { focus: false }),
keyboard: input.interactive === true,
tooltip:
input.interactive !== true
? false
: interactiveTooltip === true
? tooltip
: { use: tooltip, ...interactiveTooltip },
}
if (isResponsiveChartDefinition(presentation)) {
return defineChart(presentation, behavior)
}
return defineChart(presentation, behavior)
}
function applyCatalogPreviewFocus<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
host: ReturnType<typeof mountChart<TDatum, TXValue, TYValue>>,
input: ConformanceInput,
options: CatalogPreviewOptions<TDatum, TXValue, TYValue>,
) {
if (input.preview !== true || !options.focus) return
host.interaction.setControlledFocus(options.focus(host.getScene(), input), {
source: 'programmatic',
})
}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/transforms/learning-poverty.ts96 lines · dependency
shared/transforms/learning-poverty.ts
import { learningPoverty } from '@charts-poc/demo-data/learning-poverty'
import { geoCentroid } from 'd3-geo'
import { worldCountries } from '../fixtures/country-atlas'
import { simplifyPolygonGeometry } from '../fixtures/simplify-geo'
import type { LearningPovertyRow } from '@charts-poc/demo-data/learning-poverty'
import type { ExtendedFeature, GeoGeometryObjects } from 'd3-geo'
import type { CountryFeature, CountryGeometry } from '../fixtures/country-atlas'
type PointGeometry = Extract<GeoGeometryObjects, { type: 'Point' }>
export interface LearningPovertyProperties extends LearningPovertyRow {
name: string
}
export type LearningPovertyCountry = ExtendedFeature<
CountryGeometry,
LearningPovertyProperties
>
export type LearningPovertyPoint = ExtendedFeature<
PointGeometry,
LearningPovertyProperties
>
// The source uses World Bank names while world-atlas uses Natural Earth names.
// Tiny states absent from the 110m atlas remain unmatched.
const naturalEarthNameBySourceName: Readonly<Record<string, string>> = {
'Congo, Dem Rep': 'Dem. Rep. Congo',
'Congo, Rep': 'Congo',
'Cote d’Ivoire': "Côte d'Ivoire",
'Czech Republic': 'Czechia',
'Dominican Republic': 'Dominican Rep.',
'Egypt, Arab Rep': 'Egypt',
'Iran, Islamic Rep': 'Iran',
'Korea, Rep': 'South Korea',
'Kyrgyz Republic': 'Kyrgyzstan',
'Russian Federation': 'Russia',
'Slovak Republic': 'Slovakia',
'United States': 'United States of America',
'Yemen, Rep': 'Yemen',
}
const countryByName = new Map(
worldCountries.map((country) => [country.properties.name, country]),
)
export const learningPovertyCountries: readonly LearningPovertyCountry[] =
learningPoverty.flatMap((row) => {
const sourceName = row['Country Name']
const atlasName = naturalEarthNameBySourceName[sourceName] ?? sourceName
const country = countryByName.get(atlasName)
return country ? [joinCountry(country, row)] : []
})
export const previewLearningPovertyCountries: readonly LearningPovertyCountry[] =
learningPovertyCountries.map((country) => ({
...country,
geometry: simplifyPolygonGeometry(country.geometry, 2),
}))
if (learningPovertyCountries.length !== 95) {
throw new TypeError(
`Expected 95 learning-poverty countries in world-atlas, got ${learningPovertyCountries.length}`,
)
}
export const learningPovertyPoints: readonly LearningPovertyPoint[] =
learningPovertyCountries.map((country) => ({
type: 'Feature',
id: country.id,
geometry: {
type: 'Point',
coordinates: geoCentroid(country),
},
properties: country.properties,
}))
// Largest symbols render first so smaller countries remain selectable.
export const learningPovertyPointsByPopulation: readonly LearningPovertyPoint[] =
[...learningPovertyPoints].sort(
(left, right) => right.properties.population - left.properties.population,
)
function joinCountry(
country: CountryFeature,
row: LearningPovertyRow,
): LearningPovertyCountry {
return {
type: 'Feature',
id: country.id,
geometry: country.geometry,
properties: {
name: country.properties.name,
...row,
},
}
}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 }
}