TanStack
Catalog

Expanding pinned energy tooltip

interaction

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&apos;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 }
}