455 lines · 3 files · 12.2 kB
cases/120-themed-interactive-area/example.tsx302 lines · entry
cases/120-themed-interactive-area/example.tsx
import { useMemo, useState } from 'react'
import { defineChart } from '@tanstack/charts'
import { motion } from '@tanstack/charts/motion'
import { RendererChart } from '@tanstack/charts/react/tooltip'
import { tooltip } from '@tanstack/charts/tooltip'
import {
formatThemedAreaTooltip,
themedAreaRanges,
themedAreaRangeDays,
themedAreaRows,
} from './model'
import type { ChartTooltipOptions } from '@tanstack/charts'
import type { ThemedAreaRange, ThemedAreaRow } from './model'
import './styles.css'
import { areaY, crosshair, d3Curve, dot, lineY } from '@tanstack/charts'
import { focusGroupX } from '@tanstack/charts/focus'
import { decorative } from '@tanstack/charts/mark/decorative'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { curveMonotoneX } from 'd3-shape'
import { formatThemedAreaTick } from './model'
import type { ChartMotionSpringTransition } from '@tanstack/charts'
export const exampleAriaLabel =
'Daily visitors with selectable 7, 30, and 90 day ranges'
export const themedAreaTooltip: ChartTooltipOptions<ThemedAreaRow> = {
anchor: 'point',
placement: ['top', 'right', 'left', 'bottom'],
className: 'themed-area-tooltip',
offset: 10,
format: ({ datum }) => formatThemedAreaTooltip(datum),
}
export function createExampleChart(
range: ThemedAreaRange,
revision: number,
width: number,
height: number,
previewMode = false,
) {
const rows = themedAreaRows(range, revision)
const input = {
width,
height,
preview: previewMode,
}
const first = rows[0]
const last = rows.at(-1)
if (!first || !last) throw new TypeError('The themed area needs data')
const maximum = Math.max(...rows.map((row) => row.visitors))
const yMaximum = Math.max(500, Math.ceil((maximum * 1.08) / 100) * 100)
const preview = input.preview === true
const dateTicks = evenlySpacedDates(first.date, last.date, preview ? 3 : 5)
return defineChart(
{
motion: { transition: themedAreaSpring },
marks: [
decorative(
areaY(rows, {
id: 'visitor-area',
x: 'date',
y: 'visitors',
key: 'id',
fill: 'url(#themed-area-fill)',
fillOpacity: 1,
curve: smooth,
}),
),
decorative(
lineY(rows, {
id: 'visitor-line',
x: 'date',
y: 'visitors',
key: 'id',
stroke: accent,
strokeWidth: preview ? 2 : 2.4,
curve: smooth,
}),
),
dot(rows, {
id: 'visitor-points',
x: 'date',
y: 'visitors',
key: 'id',
r: 3,
fill: accent,
fillOpacity: 0,
stroke: surface,
strokeOpacity: 0,
strokeWidth: 2,
states: [
{
when: { focus: 'group' },
style: {
fillOpacity: 1,
strokeOpacity: 1,
r: preview ? 3.75 : 4.5,
},
transition: {
type: 'tween',
duration: 140,
easing: 'ease-out',
},
},
{
when: { focus: 'primary' },
style: {
fillOpacity: 1,
strokeOpacity: 1,
r: preview ? 4.25 : 5,
},
transition: {
type: 'tween',
duration: 140,
easing: 'ease-out',
},
},
],
}),
crosshair<Date, number>({
id: 'visitor-crosshair',
x: {
stroke: grid,
strokeOpacity: 0.34,
strokeWidth: 1,
strokeDasharray: '3 4',
},
y: false,
motion: { transition: themedAreaSpring },
}),
],
scales: {
x: {
scale: scaleUtc().domain([first.date, last.date]),
axis: {
line: false,
ticks: {
values: dateTicks,
size: 0,
padding: preview ? 5 : 8,
format: formatThemedAreaTick,
},
tickLabels: {
fontSize: preview ? 8 : 10,
opacity: 0.62,
thin: { minGap: preview ? 5 : 12, priority: 'ends' },
},
},
},
y: {
scale: scaleLinear().domain([0, yMaximum]),
grid: true,
axis: {
line: false,
ticks: {
values: [0, yMaximum / 3, (yMaximum * 2) / 3, yMaximum],
size: 0,
},
tickLabels: false,
},
},
},
gradients: [
{
id: 'themed-area-fill',
x1: 0,
y1: 0,
x2: 0,
y2: 1,
stops: [
{ offset: 0, color: accent, opacity: 0.34 },
{ offset: 0.58, color: accent, opacity: 0.13 },
{ offset: 1, color: accent, opacity: 0.015 },
],
},
],
theme: {
background: 'transparent',
foreground,
muted,
grid,
palette: [accent],
},
focus: focusGroupX,
focusRing: false,
maxFocusDistance: Number.POSITIVE_INFINITY,
tooltip: false,
keyboard: true,
clip: true,
margin: preview
? { top: 10, right: 18, bottom: 22, left: 18 }
: { top: 12, right: 18, bottom: 28, left: 18 },
},
{
keyboard: true,
tooltip: { use: tooltip, ...themedAreaTooltip },
},
)
}
export interface ExampleProps {
width?: number
height?: number
revision?: number
}
export default function Example({
width = 640,
height = 480,
revision = 0,
}: ExampleProps = {}) {
const [range, setRange] = useState<ThemedAreaRange>('30d')
const definition = useMemo(
() => createExampleChart(range, revision, width, height - 70),
[height, range, revision, width],
)
const renderer = useMemo(
() =>
motion({
initial: 'always',
transition: themedAreaSpring,
respectReducedMotion: true,
}),
[],
)
return (
<section className="themed-area-card" style={{ width, height }}>
<header className="themed-area-card__header">
<div>
<h2 className="themed-area-card__title">Traffic</h2>
<p className="themed-area-card__description">Daily visitors</p>
</div>
<div
className="themed-area-card__ranges"
role="group"
aria-label="Date range"
>
{themedAreaRanges.map((value) => (
<button
key={value}
type="button"
className="themed-area-card__range"
aria-label={`Last ${themedAreaRangeDays[value]} days`}
aria-pressed={value === range}
onClick={() => setRange(value)}
>
{value.toUpperCase()}
</button>
))}
</div>
</header>
<RendererChart
definition={definition}
renderer={renderer}
width={width}
height={Math.max(120, height - 70)}
ariaLabel={exampleAriaLabel}
ariaDescription="Choose a range, then move across the chart or use the arrow keys to inspect daily visitor totals."
/>
</section>
)
}
export interface ThemedAreaChartInput {
width: number
height: number
preview?: boolean
}
export const themedAreaSpring = {
type: 'spring',
stiffness: 190,
damping: 24,
mass: 0.82,
restDelta: 0.02,
restSpeed: 0.02,
} satisfies ChartMotionSpringTransition
const smooth = d3Curve(curveMonotoneX)
const accent = 'var(--themed-area-accent, #3b82f6)'
const surface = 'var(--themed-area-surface, Canvas)'
const foreground = 'var(--themed-area-foreground, CanvasText)'
const muted = 'var(--themed-area-muted, GrayText)'
const grid = 'var(--themed-area-grid, CanvasText)'
function evenlySpacedDates(first: Date, last: Date, count: number) {
const start = first.getTime()
const span = last.getTime() - start
return Array.from(
{ length: count },
(_value, index) => new Date(start + (span * index) / (count - 1)),
)
}cases/120-themed-interactive-area/model.ts75 lines · dependency
cases/120-themed-interactive-area/model.ts
export const themedAreaRanges = ['7d', '30d', '90d'] as const
export type ThemedAreaRange = (typeof themedAreaRanges)[number]
export interface ThemedAreaRow {
id: string
date: Date
visitors: number
}
export const themedAreaRangeDays: Record<ThemedAreaRange, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
}
const lastDay = Date.UTC(2026, 5, 30)
const day = 24 * 60 * 60 * 1_000
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
const tooltipDateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
export function themedAreaRows(
range: ThemedAreaRange,
revision = 0,
): readonly ThemedAreaRow[] {
const rows = Array.from({ length: 90 }, (_value, index) => {
const date = new Date(lastDay - (89 - index) * day)
const weekly = Math.sin((index / 7) * Math.PI * 2) * 34
const season = Math.sin(((index + 5) / 29) * Math.PI * 2) * 72
const trend = index * 0.82
const campaigns =
pulse(index, 20, 4.5, 92) +
pulse(index, 51, 6, 126) +
pulse(index, 77, 3.8, 108)
const revisionShift =
Math.sin(((index + revision * 3) / 11) * Math.PI * 2) *
Math.min(26, Math.abs(revision) * 6)
const visitors = Math.max(
96,
Math.round(238 + weekly + season + trend + campaigns + revisionShift),
)
return {
id: date.toISOString().slice(0, 10),
date,
visitors,
}
})
return rows.slice(-themedAreaRangeDays[range])
}
export function formatThemedAreaTick(date: Date) {
return dateFormatter.format(date)
}
export function formatThemedAreaTooltip(row: ThemedAreaRow) {
return `${tooltipDateFormatter.format(row.date)} · ${row.visitors.toLocaleString('en-US')} visitors`
}
function pulse(index: number, center: number, width: number, height: number) {
const distance = (index - center) / width
return Math.exp(-(distance * distance)) * height
}cases/120-themed-interactive-area/styles.css78 lines · dependency
cases/120-themed-interactive-area/styles.css
.themed-area-card {
--themed-area-surface: light-dark(#ffffff, #09090b);
--themed-area-foreground: light-dark(#18181b, #fafafa);
--themed-area-muted: light-dark(#71717a, #a1a1aa);
--themed-area-grid: light-dark(#64748b, #94a3b8);
--themed-area-border: light-dark(
rgba(15, 23, 42, 0.11),
rgba(255, 255, 255, 0.1)
);
--themed-area-accent: light-dark(#2563eb, #60a5fa);
--ts-chart-tooltip-background: color-mix(
in srgb,
var(--themed-area-surface) 94%,
transparent
);
--ts-chart-tooltip-color: var(--themed-area-foreground);
--ts-chart-tooltip-border: 1px solid var(--themed-area-border);
--ts-chart-tooltip-border-radius: 8px;
--ts-chart-tooltip-shadow: 0 8px 24px rgba(15, 23, 42, 0.13);
box-sizing: border-box;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
color: var(--themed-area-foreground);
background: var(--themed-area-surface);
border: 1px solid var(--themed-area-border);
border-radius: 16px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.themed-area-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px 8px;
}
.themed-area-card__title {
margin: 0;
font-size: 14px;
line-height: 1.25;
}
.themed-area-card__description {
margin: 4px 0 0;
color: var(--themed-area-muted);
font-size: 11px;
}
.themed-area-card__ranges {
display: inline-flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--themed-area-border);
border-radius: 9px;
}
.themed-area-card__range {
min-width: 38px;
min-height: 30px;
padding: 0 9px;
color: var(--themed-area-muted);
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
font:
600 10px/1 ui-sans-serif,
system-ui,
sans-serif;
}
.themed-area-card__range[aria-pressed='true'] {
color: var(--themed-area-foreground);
background: var(--themed-area-surface);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.11);
}