1,753 lines · 7 files · 45.6 kB
cases/84-pinned-nested-chart-tooltip/model.ts221 lines · dependency
cases/84-pinned-nested-chart-tooltip/model.ts
import type { ChartTooltipContent } from '@tanstack/charts'
import type { ConformanceTarget } from '../../types'
export const energyColors = {
consumption: '#8b8d90',
household: '#1685ff',
heatPump: '#e82285',
hotWater: '#ee4c91',
evCharging: '#5cbd68',
generationMuted: '#f4c675',
generation: '#f2a900',
exported: '#f8d99a',
} as const
export const energyAnnualOverview = {
generation: 3_509,
consumption: 17_847,
} as const
export const energyMonthIds = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
] as const
export type EnergyMonthId = (typeof energyMonthIds)[number]
export interface EnergyMonth {
readonly id: EnergyMonthId
readonly month: string
readonly monthShort: string
readonly household: number
readonly heatPump: number
readonly hotWater: number
readonly evCharging: number
readonly consumption: number
readonly generation: number
readonly usedOnSite: number
readonly exported: number
readonly householdStart: number
readonly householdEnd: number
readonly heatPumpStart: number
readonly heatPumpEnd: number
readonly hotWaterStart: number
readonly hotWaterEnd: number
readonly evChargingStart: number
readonly evChargingEnd: number
}
export interface EnergyBreakdownPart {
readonly id: string
readonly label: string
readonly value: number
readonly start: number
readonly end: number
readonly color: string
}
const baseMonths = [
['jan', 'January', 'Jan', 783, 637, 468, 688, 188, 180, 8],
['feb', 'February', 'Feb', 672, 531, 400, 563, 219, 217, 2],
['mar', 'March', 'Mar', 668, 466, 397, 614, 262, 257, 5],
['apr', 'April', 'Apr', 570, 342, 352, 524, 375, 322, 53],
['may', 'May', 'May', 376, 191, 232, 352, 427, 219, 208],
['jun', 'June', 'Jun', 241, 122, 150, 225, 482, 169, 313],
['jul', 'July', 'Jul', 246, 91, 155, 233, 367, 150, 217],
['aug', 'August', 'Aug', 241, 96, 155, 233, 354, 138, 216],
['sep', 'September', 'Sep', 242, 135, 147, 223, 304, 142, 162],
['oct', 'October', 'Oct', 362, 239, 218, 335, 258, 185, 73],
['nov', 'November', 'Nov', 577, 449, 345, 524, 174, 171, 3],
['dec', 'December', 'Dec', 607, 495, 365, 570, 100, 99, 1],
] as const satisfies readonly (readonly [
EnergyMonthId,
string,
string,
number,
number,
number,
number,
number,
number,
number,
])[]
export function energyMonths(revision = 0): readonly EnergyMonth[] {
return baseMonths.map(
([
id,
month,
monthShort,
household,
heatPump,
hotWater,
baseEvCharging,
generation,
usedOnSite,
exported,
]) => {
const evCharging =
id === 'dec' && revision % 2 === 1
? baseEvCharging + 18
: baseEvCharging
const householdStart = 0
const householdEnd = household
const heatPumpStart = householdEnd
const heatPumpEnd = heatPumpStart + heatPump
const hotWaterStart = heatPumpEnd
const hotWaterEnd = hotWaterStart + hotWater
const evChargingStart = hotWaterEnd
const evChargingEnd = evChargingStart + evCharging
return {
id,
month,
monthShort,
household,
heatPump,
hotWater,
evCharging,
consumption: evChargingEnd,
generation,
usedOnSite,
exported,
householdStart,
householdEnd,
heatPumpStart,
heatPumpEnd,
hotWaterStart,
hotWaterEnd,
evChargingStart,
evChargingEnd,
}
},
)
}
export function isEnergyMonthId(value: unknown): value is EnergyMonthId {
return energyMonthIds.some((id) => id === value)
}
export function monthFromTarget(target: ConformanceTarget) {
if (target.view !== undefined && target.view !== 'main') return null
const [kind, id] = target.anchor.split(':')
return kind === 'month' && isEnergyMonthId(id) ? id : null
}
export function consumptionBreakdown(
month: EnergyMonth,
): readonly EnergyBreakdownPart[] {
return [
{
id: 'household',
label: 'Household',
value: month.household,
start: month.householdStart,
end: month.householdEnd,
color: energyColors.household,
},
{
id: 'heat-pump',
label: 'Heat pump',
value: month.heatPump,
start: month.heatPumpStart,
end: month.heatPumpEnd,
color: energyColors.heatPump,
},
{
id: 'hot-water',
label: 'Hot water',
value: month.hotWater,
start: month.hotWaterStart,
end: month.hotWaterEnd,
color: energyColors.hotWater,
},
{
id: 'ev-charging',
label: 'EV charging',
value: month.evCharging,
start: month.evChargingStart,
end: month.evChargingEnd,
color: energyColors.evCharging,
},
]
}
export function energyTooltipContent(
points: readonly { readonly datum: EnergyMonth }[],
_pinned: boolean,
): ChartTooltipContent {
const month = points[0]?.datum
if (!month) return { rows: [] }
return {
title: month.month,
rows: [
{
label: 'Consumption',
value: formatEnergy(month.consumption),
},
{
label: 'Generation',
value: formatEnergy(month.generation),
},
],
}
}
export function formatEnergy(value: number) {
return `${value.toLocaleString('en-US')} kWh`
}
export function formatPercent(value: number) {
return `${Math.round(value * 100)}%`
}cases/84-pinned-nested-chart-tooltip/tanstack.ts1 line · entry
cases/84-pinned-nested-chart-tooltip/tanstack.ts
export { mount } from './view'cases/84-pinned-nested-chart-tooltip/tooltip-body.tsx415 lines · dependency
cases/84-pinned-nested-chart-tooltip/tooltip-body.tsx
import type { ReactNode } from 'react'
import {
consumptionBreakdown,
energyColors,
formatEnergy,
formatPercent,
} from './model'
import type { EnergyMonth } from './model'
interface EnergyTooltipBodyProps {
readonly month: EnergyMonth
readonly pinned: boolean
readonly dismiss: () => void
readonly consumptionChart: ReactNode
}
export function EnergyTooltipBody({
month,
pinned,
dismiss,
consumptionChart,
}: EnergyTooltipBodyProps) {
const coverageShare = month.usedOnSite / month.consumption
const usedShare = month.usedOnSite / month.generation
const exportedShare = month.exported / month.generation
return (
<div className="energy-tooltip" data-expanded={String(pinned)}>
<div className="energy-tooltip__summary">
<div className="ts-chart-tooltip__title">{month.month}</div>
{pinned ? (
<button
className="energy-tooltip__close"
type="button"
data-energy-tooltip-close
aria-label="Close energy details"
onPointerDown={(event) => event.stopPropagation()}
onClick={dismiss}
>
<Chevron expanded />
</button>
) : (
<span className="energy-tooltip__toggle" aria-hidden="true">
<Chevron expanded={false} />
</span>
)}
</div>
<MetricRow label="Consumption" value={formatEnergy(month.consumption)} />
<div className="energy-tooltip__compact-generation">
<div className="energy-tooltip__compact-generation-inner">
<MetricRow
label="Generation"
value={formatEnergy(month.generation)}
/>
</div>
</div>
<div className="energy-tooltip__reveal" aria-hidden={!pinned}>
<div className="energy-tooltip__reveal-inner">
<div className="energy-tooltip__details">
<section aria-label="Consumption mix">
<div className="energy-tooltip__mini-chart">
{consumptionChart}
</div>
{consumptionBreakdown(month).map((part) => (
<DetailRow
key={part.id}
color={part.color}
label={part.label}
value={formatEnergy(part.value)}
/>
))}
</section>
<section aria-label="Generation use">
<MetricRow
className="energy-tooltip__generation-heading"
label="Generation"
summary={false}
value={formatEnergy(month.generation)}
/>
<div
className="energy-tooltip__generation-bar"
aria-hidden="true"
>
<span
style={{
flex: month.usedOnSite,
background: energyColors.generation,
}}
/>
<span
style={{
flex: month.exported,
background: energyColors.exported,
}}
/>
</div>
<DetailRow
color={energyColors.generation}
label="Used on site"
value={formatPercent(usedShare)}
/>
<DetailRow
color={energyColors.exported}
label="Exported"
value={formatPercent(exportedShare)}
/>
</section>
</div>
</div>
</div>
<p className="energy-tooltip__footer">
Solar covered {formatPercent(coverageShare)} of this month's
consumption, with the rest coming from the grid.
</p>
</div>
)
}
function Chevron({ expanded }: { readonly expanded: boolean }) {
return (
<svg
className="energy-tooltip__chevron"
viewBox="0 0 12 12"
aria-hidden="true"
>
<path
d={
expanded
? 'M3 2.5 6 5 9 2.5M3 9.5 6 7 9 9.5'
: 'm3 4 3-3 3 3M3 8l3 3 3-3'
}
/>
</svg>
)
}
function MetricRow({
className,
label,
summary = true,
value,
}: {
readonly className?: string
readonly label: string
readonly summary?: boolean
readonly value: string
}) {
return (
<div
className={[
'energy-tooltip__metric-row',
summary ? 'ts-chart-tooltip__row' : null,
className,
]
.filter(Boolean)
.join(' ')}
>
<span>{label}</span>
<span>{value}</span>
</div>
)
}
function DetailRow({
color,
label,
value,
}: {
readonly color: string
readonly label: string
readonly value: string
}) {
return (
<div className="energy-tooltip__detail-row" data-energy-detail-row>
<span
className="energy-tooltip__swatch"
style={{ background: color }}
aria-hidden="true"
/>
<span>{label}</span>
<span>{value}</span>
</div>
)
}
export const energyTooltipStyles = `
.energy-overview-card .ts-chart:focus,
.energy-overview-card [role='listbox']:focus {
outline: none;
}
.ts-chart-tooltip.energy-tooltip-surface,
.energy-reference-tooltip {
box-sizing: border-box;
width: 292px;
max-width: calc(100vw - 24px) !important;
padding: 0 !important;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.1) !important;
border-radius: 10px !important;
background: #2b2b2e !important;
color: #f4f4f5 !important;
box-shadow: 0 14px 34px rgb(0 0 0 / 0.3) !important;
font: 500 12px/1.35 system-ui, sans-serif !important;
}
.energy-reference-tooltip {
position: absolute;
z-index: 2;
}
.energy-tooltip {
padding: 12px;
}
.energy-tooltip__summary {
position: relative;
min-width: 0;
padding-right: 28px;
}
.energy-tooltip .ts-chart-tooltip__title {
display: flex;
align-items: center;
min-height: 18px;
margin: 0 0 6px;
color: #f4f4f5;
font-size: 12px;
font-weight: 650;
}
.energy-tooltip__metric-row {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto !important;
align-items: center !important;
column-gap: 12px !important;
font-variant-numeric: tabular-nums;
}
.energy-tooltip__metric-row > :last-child {
color: #fafafa;
font-weight: 620;
text-align: right;
white-space: nowrap;
}
.energy-tooltip__detail-row {
display: grid;
grid-template-columns: 3px minmax(0, 1fr) auto;
align-items: center;
column-gap: 8px;
min-height: 17px;
color: #d4d4d8;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.energy-tooltip__detail-row > :last-child {
color: #f4f4f5;
font-weight: 600;
text-align: right;
white-space: nowrap;
}
.energy-tooltip__swatch {
display: block;
width: 3px;
height: 10px;
border-radius: 999px;
}
.energy-tooltip__close,
.energy-tooltip__toggle {
position: absolute;
top: -9px;
right: -9px;
display: grid;
width: 36px;
height: 36px;
place-items: center;
color: #a1a1aa;
}
.energy-tooltip__close {
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: #a1a1aa;
cursor: pointer;
pointer-events: auto;
}
.energy-tooltip__chevron {
width: 12px;
height: 12px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
}
.energy-tooltip__close:hover,
.energy-tooltip__close:focus-visible {
background: rgb(255 255 255 / 0.08);
color: #fafafa;
outline: none;
}
.energy-tooltip__close:focus-visible {
box-shadow: inset 0 0 0 2px #f5b942;
}
.energy-tooltip__reveal {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 160ms ease;
}
.energy-tooltip[data-expanded='true'] .energy-tooltip__reveal {
grid-template-rows: 1fr;
opacity: 1;
}
.energy-tooltip__reveal-inner {
min-height: 0;
overflow: hidden;
}
.energy-tooltip__details {
display: grid;
gap: 12px;
margin-top: 5px;
transform: translateY(-4px);
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.energy-tooltip[data-expanded='true'] .energy-tooltip__details {
transform: translateY(0);
}
.energy-tooltip__details section {
display: grid;
gap: 5px;
}
.energy-tooltip__compact-generation {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
transition:
grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 120ms ease;
}
.energy-tooltip[data-expanded='true'] .energy-tooltip__compact-generation {
grid-template-rows: 0fr;
opacity: 0;
}
.energy-tooltip__compact-generation-inner {
min-height: 0;
overflow: hidden;
}
.energy-tooltip__generation-heading {
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid rgb(255 255 255 / 0.09);
}
.energy-tooltip__mini-chart,
.energy-tooltip__generation-bar {
width: 100%;
height: 8px;
overflow: hidden;
border-radius: 3px;
background: rgb(255 255 255 / 0.08);
}
.energy-tooltip__mini-chart svg {
display: block;
width: 100%;
height: 8px;
}
.energy-tooltip__generation-bar {
display: flex;
}
.energy-tooltip__footer {
margin: 10px -12px -12px;
padding: 10px 12px 11px;
border-top: 1px solid rgb(255 255 255 / 0.07);
background: #222225;
color: #a8a8af;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) {
.energy-tooltip__compact-generation,
.energy-tooltip__reveal,
.energy-tooltip__details {
transition: none;
}
}
`cases/84-pinned-nested-chart-tooltip/view.tsx539 lines · dependency
cases/84-pinned-nested-chart-tooltip/view.tsx
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
import {
areaY,
barX,
barY,
d3Curve,
defineChart,
dot,
lineY,
ruleX,
tickY,
whenFocused,
} from '@tanstack/charts'
import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources'
import { Chart as NestedChart } from '@tanstack/charts/react'
import { Chart as TooltipChart } from '@tanstack/charts/react/tooltip'
import { tooltip } from '@tanstack/charts/tooltip'
import { scaleBand, scaleLinear } from 'd3-scale'
import { curveMonotoneX } from 'd3-shape'
import { catalogPreviewDefinition } from '../../shared/preview'
import { reactMount } from '../../shared/react-mount'
import {
consumptionBreakdown,
energyAnnualOverview,
energyColors,
energyMonths,
energyTooltipContent,
formatEnergy,
monthFromTarget,
} from './model'
import { EnergyTooltipBody, energyTooltipStyles } from './tooltip-body'
import type { ChartScene } from '@tanstack/charts'
import type { ConformanceTestDriver } from '../../types'
import type { ReactConformanceProps } from '../../shared/react-mount'
import type { EnergyMonth, EnergyMonthId } from './model'
const EnergyTooltipExample = forwardRef<
ConformanceTestDriver,
ReactConformanceProps
>(function EnergyTooltipExample({ input, idPrefix }, ref) {
const viewRef = useRef<HTMLDivElement>(null)
const focusedIdRef = useRef<EnergyMonthId | null>(null)
const previewPinnedRevisionRef = useRef<number | null>(null)
const renderedRef = useRef<{
scene: ChartScene<EnergyMonth, string, number>
svg: SVGSVGElement
} | null>(null)
const rows = useMemo(() => energyMonths(input.revision), [input.revision])
const chartWidth = Math.max(1, input.width - 24)
const chartHeight = Math.max(1, input.height - 48)
const annualConsumption = rows.reduce(
(total, month) => total + month.consumption,
0,
)
const mainDefinition = useMemo(
() => energyDefinition(rows, chartWidth),
[chartWidth, rows],
)
useImperativeHandle(
ref,
() => ({
resolveTarget(target) {
if (target.anchor === 'tooltip:close') {
const close =
viewRef.current?.ownerDocument.querySelector<HTMLElement>(
'[data-energy-tooltip-close]',
)
return close ? center(close) : null
}
const monthId = monthFromTarget(target)
if (!monthId) return null
return pointCoordinate(renderedRef.current, monthId)
},
readState() {
const document = viewRef.current?.ownerDocument
const surface = document?.querySelector<HTMLElement>(
'.ts-chart-tooltip.energy-tooltip-surface',
)
const tooltip = surface && !surface.hidden ? surface : null
const body = tooltip?.querySelector<HTMLElement>('.energy-tooltip')
const reveal = tooltip?.querySelector<HTMLElement>(
'.energy-tooltip__reveal',
)
return {
focusedMonth: focusedIdRef.current,
tooltip: {
visible: Boolean(tooltip),
pinned: tooltip?.dataset.sticky === 'true',
role: tooltip?.getAttribute('role') ?? null,
inert:
tooltip
?.querySelector('.ts-chart-tooltip__body')
?.hasAttribute('inert') ?? false,
month:
tooltip
?.querySelector('.ts-chart-tooltip__title')
?.textContent?.trim() ?? null,
summaryRowCount:
tooltip?.querySelectorAll('.ts-chart-tooltip__row').length ?? 0,
detailRowCount:
tooltip?.querySelectorAll('[data-energy-detail-row]').length ?? 0,
detailsExpanded: body?.dataset.expanded === 'true',
detailHeight: Math.round(
reveal?.getBoundingClientRect().height ?? 0,
),
nestedBarCount:
tooltip?.querySelectorAll('.ts-chart__bar rect').length ?? 0,
closeVisible: Boolean(
tooltip?.querySelector('[data-energy-tooltip-close]'),
),
text: tooltip?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
},
}
},
}),
[],
)
if (input.preview) {
return (
<TooltipChart
idPrefix={idPrefix ? `${idPrefix}-main` : undefined}
definition={catalogPreviewDefinition(mainDefinition)}
initialWidth={input.width}
aspectRatio={input.width / input.height}
renderSvg={renderChartSvgWithResources}
ariaLabel="Annual household energy overview"
ariaDescription="A gray area tracks monthly electricity consumption. Stacked gold bars show solar energy used on site and exported."
onRender={({ scene, interaction }) => {
if (previewPinnedRevisionRef.current === input.revision) return
const point = scene.points.find(
(candidate) =>
candidate.markId === 'consumption-points' &&
candidate.datum.id === 'jun',
)
if (!point) return
previewPinnedRevisionRef.current = input.revision
interaction.setControlledFocus(point, {
source: 'programmatic',
pinned: true,
})
}}
renderTooltipBody={({ points }) => {
const month = points[0]?.datum
return month ? (
<ConsumptionMixChart
month={month}
idPrefix={idPrefix ? `${idPrefix}-nested` : undefined}
catalogPreview
/>
) : null
}}
/>
)
}
return (
<div
ref={viewRef}
data-conformance-view="main"
role="region"
aria-label="Monthly household energy with an expanding pinned tooltip"
style={{
position: 'relative',
width: input.width,
height: input.height,
paddingTop: 4,
background: 'Canvas',
color: 'CanvasText',
boxSizing: 'border-box',
}}
>
<style>{energyTooltipStyles}</style>
<header
style={{
display: 'flex',
height: 36,
alignItems: 'center',
padding: '0 24px',
font: '500 12px/1.3 system-ui, sans-serif',
}}
>
<strong style={{ fontSize: 13, fontWeight: 680 }}>
Annual overview
</strong>
</header>
<div
className="energy-overview-card"
style={{
position: 'relative',
width: chartWidth,
height: chartHeight,
margin: '0 12px',
border: '1px solid color-mix(in srgb, CanvasText 8%, transparent)',
borderRadius: 7,
boxSizing: 'border-box',
}}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
zIndex: 1,
top: 14,
left: 16,
display: 'flex',
gap: 26,
pointerEvents: 'none',
font: '500 11px/1.2 system-ui, sans-serif',
}}
>
<AnnualMetric
label="Energy generated"
value={formatEnergy(energyAnnualOverview.generation)}
/>
<AnnualMetric
label="Total consumption"
value={formatEnergy(annualConsumption)}
/>
</div>
<TooltipChart
idPrefix={idPrefix ? `${idPrefix}-main` : undefined}
definition={mainDefinition}
initialWidth={chartWidth}
height={chartHeight}
renderSvg={renderChartSvgWithResources}
ariaLabel="Annual household energy overview"
ariaDescription="A gray area tracks monthly electricity consumption. Stacked gold bars show solar energy used on site and exported. Hover or focus a month for totals, then click or press Enter to expand the breakdown."
onFocusGroupChange={(points) => {
focusedIdRef.current = points[0]?.datum.id ?? null
}}
onRender={({ scene, svg }) => {
renderedRef.current = { scene, svg }
}}
renderTooltipBody={({ points, defaultBody, pinned, dismiss }) => {
const month = points[0]?.datum
if (!month) return defaultBody
return (
<EnergyTooltipBody
month={month}
pinned={pinned}
dismiss={dismiss}
consumptionChart={
<ConsumptionMixChart
month={month}
idPrefix={idPrefix ? `${idPrefix}-nested` : undefined}
/>
}
/>
)
}}
/>
</div>
</div>
)
})
export const catalogComponent = EnergyTooltipExample
export const mount = reactMount(EnergyTooltipExample)
function energyDefinition(rows: readonly EnergyMonth[], chartWidth: number) {
const months = rows.map((row) => row.monthShort)
const tooltipPlacement =
chartWidth >= 500
? ('right' as const)
: (['right', 'left', 'top', 'bottom'] as const)
return defineChart(
defineChart({
marks: [
areaY(rows, {
id: 'consumption-area',
x: 'monthShort',
y: 'consumption',
fill: energyColors.consumption,
fillOpacity: 0.13,
curve: d3Curve(curveMonotoneX),
}),
barY(rows, {
id: 'used-on-site',
x: 'monthShort',
y: 'usedOnSite',
fill: energyColors.generationMuted,
inset: 0.5,
states: [
{
when: { focus: 'group' },
style: { fill: energyColors.generation },
transition: {
type: 'tween',
duration: 120,
easing: 'ease-out',
},
},
],
}),
barY(rows, {
id: 'exported',
x: 'monthShort',
y1: 'usedOnSite',
y2: 'generation',
fill: 'url(#energy-exported-hatch)',
inset: 0.5,
radius: 3,
}),
lineY(rows, {
id: 'consumption-line',
x: 'monthShort',
y: 'consumption',
stroke: energyColors.consumption,
strokeWidth: 1.6,
curve: d3Curve(curveMonotoneX),
}),
whenFocused(
ruleX(rows, {
id: 'focused-month-guide',
x: 'monthShort',
stroke: 'CanvasText',
strokeOpacity: 0.45,
strokeWidth: 1,
strokeDasharray: '4 4',
}),
{ match: 'x' },
),
whenFocused(
tickY(rows, {
id: 'focused-month-axis-marker',
x: 'monthShort',
y: () => 0,
stroke: 'CanvasText',
strokeWidth: 1.5,
}),
{ match: 'x' },
),
dot(rows, {
id: 'consumption-points',
x: 'monthShort',
y: 'consumption',
fill: 'Canvas',
fillOpacity: 0,
stroke: energyColors.consumption,
strokeOpacity: 0,
strokeWidth: 1.4,
r: 4,
states: [
{
when: { focus: 'group' },
style: {
r: 4,
fillOpacity: 1,
strokeOpacity: 1,
strokeWidth: 1.8,
},
transition: {
type: 'tween',
duration: 150,
easing: 'ease-out',
},
},
{
when: { focus: 'primary', pinned: true },
style: {
r: 4.5,
fillOpacity: 1,
strokeOpacity: 1,
strokeWidth: 2,
},
transition: {
type: 'tween',
duration: 180,
easing: 'ease-out',
},
},
],
}),
],
x: {
scale: scaleBand<string>()
.domain(months)
.paddingInner(0.16)
.paddingOuter(0.06),
axis: {
line: false,
ticks: { size: 0, padding: 8 },
},
},
y: {
scale: scaleLinear().domain([0, 2600]),
grid: true,
axis: {
line: false,
ticks: {
values: [0, 650, 1300, 1950, 2600],
size: 0,
padding: 7,
format: (value) => `${value.toLocaleString('en-US')} kWh`,
},
},
},
margin: { top: 82, right: 24, bottom: 38, left: 72 },
gradients: [
{
id: 'energy-exported-hatch',
x1: 0,
y1: 0,
x2: 1,
y2: 1,
stops: exportedHatchStops,
},
],
}),
{
svgAnimation: false,
keyboard: true,
focus: 'group-x',
focusRing: false,
tooltip: {
use: tooltip,
className: 'energy-tooltip-surface',
anchor: 'point',
placement: tooltipPlacement,
offset: 12,
content: (points, { pinned }) => energyTooltipContent(points, pinned),
},
},
)
}
function ConsumptionMixChart({
month,
idPrefix,
catalogPreview = false,
}: {
readonly month: EnergyMonth
readonly idPrefix?: string
readonly catalogPreview?: boolean
}) {
const definition = useMemo(() => {
const parts = consumptionBreakdown(month)
return defineChart(
defineChart({
marks: [
barX(parts, {
id: 'consumption-breakdown',
x1: 'start',
x2: 'end',
y: () => 'mix',
fill: (part) => part.color,
inset: 0,
}),
],
x: {
scale: scaleLinear().domain([0, month.consumption]),
axis: false,
},
y: {
scale: scaleBand<string>().domain(['mix']),
axis: false,
},
margin: 0,
}),
{ svgAnimation: false, keyboard: false, tooltip: false },
)
}, [month])
return (
<NestedChart
idPrefix={idPrefix}
className={catalogPreview ? 'energy-catalog-preview-nested' : undefined}
definition={definition}
width={264}
height={10}
ariaLabel={`${month.month} consumption split: household ${month.household} kilowatt-hours, heat pump ${month.heatPump}, hot water ${month.hotWater}, and EV charging ${month.evCharging}`}
/>
)
}
function pointCoordinate(
rendered: {
scene: ChartScene<EnergyMonth, string, number>
svg: SVGSVGElement
} | null,
id: EnergyMonthId,
) {
if (!rendered) return null
const point = rendered.scene.points.find(
(candidate) =>
candidate.markId === 'consumption-points' && candidate.datum.id === id,
)
if (!point) return null
const bounds = rendered.svg.getBoundingClientRect()
return {
x: bounds.left + (point.x / rendered.scene.width) * bounds.width,
y: bounds.top + (point.y / rendered.scene.height) * bounds.height,
focusElement: rendered.svg,
}
}
function AnnualMetric({ label, value }: { label: string; value: string }) {
const [amount, unit] = value.split(' ')
return (
<div style={{ display: 'grid', gap: 3 }}>
<span
style={{ color: 'color-mix(in srgb, CanvasText 55%, transparent)' }}
>
{label}
</span>
<strong
style={{ fontSize: 19, fontWeight: 680, letterSpacing: '-0.02em' }}
>
{amount}{' '}
<span style={{ fontSize: 11, fontWeight: 620, letterSpacing: 0 }}>
{unit}
</span>
</strong>
</div>
)
}
const exportedHatchStops = Array.from({ length: 14 }, (_, index) => {
const start = index / 14
const lineStart = (index + 0.82) / 14
const end = (index + 1) / 14
return [
{ offset: start, color: energyColors.exported },
{ offset: lineStart, color: energyColors.exported },
{ offset: lineStart, color: '#fff7e8' },
{ offset: end, color: '#fff7e8' },
]
}).flat()
function center(element: HTMLElement | SVGElement) {
const bounds = element.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: element,
}
}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 }
}