1,068 lines · 3 files · 27.0 kB
cases/84-pinned-nested-chart-tooltip/example.tsx433 lines · entry
cases/84-pinned-nested-chart-tooltip/example.tsx
import { 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 {
consumptionBreakdown,
energyAnnualOverview,
energyColors,
energyMonths,
energyTooltipContent,
formatEnergy,
} from './model'
import { EnergyTooltipBody, energyTooltipStyles } from './tooltip-body'
import type { ChartScene } from '@tanstack/charts'
import type { EnergyMonth, EnergyMonthId } from './model'
export 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(
{
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',
},
},
],
}),
],
scales: {
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),
},
},
)
}
export function ConsumptionMixChart({
month,
idPrefix,
catalogPreview = false,
}: {
readonly month: EnergyMonth
readonly idPrefix?: string
readonly catalogPreview?: boolean
}) {
const definition = useMemo(() => {
const parts = consumptionBreakdown(month)
return defineChart(
{
marks: [
barX(parts, {
id: 'consumption-breakdown',
x1: 'start',
x2: 'end',
y: () => 'mix',
fill: (part) => part.color,
inset: 0,
}),
],
scales: {
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}`}
/>
)
}
export 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>
)
}
export 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()
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
export default function EnergyTooltipExample({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const input = { width, height, revision, preview: false, interactive: true }
const idPrefix = '84-pinned-nested-chart-tooltip'
const viewRef = useRef<HTMLDivElement>(null)
const focusedIdRef = useRef<EnergyMonthId | 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],
)
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>
)
}cases/84-pinned-nested-chart-tooltip/model.ts220 lines · dependency
cases/84-pinned-nested-chart-tooltip/model.ts
import type { ChartTooltipContent } from '@tanstack/charts'
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: { view?: string; anchor: string }) {
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/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;
}
}
`