{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"brush-chart","type":"registry:ui","title":"Brush chart","description":"A dense time series with an overview strip: drag a window to zoom, resize it by its handles, and read events in place.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/brush-chart/brush-chart.tsx","type":"registry:component","target":"@components/arc/brush-chart/brush-chart.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./brush-chart.module.css\";\n\nexport interface BrushChartDatum {\n /** A point in time, as a Date or epoch milliseconds. Points must be in ascending order. */\n date: Date | number;\n value: number;\n}\n\nexport interface BrushChartAnnotation {\n date: Date | number;\n /** Short label printed beside the marker, such as \"v2 launch\". */\n label: string;\n /** Longer note for the tooltip and screen readers. */\n description?: string;\n}\n\n/** Use a brush chart for a long, dense time series where people need the whole history and a close look at any stretch of it. */\nexport interface BrushChartProps {\n data: BrushChartDatum[];\n /** What is measured, such as \"Daily active users\". Names the chart for assistive technology. */\n label: string;\n /** Unit after each value, such as \"users\". */\n unit?: string;\n formatValue?: (value: number) => string;\n /** Formats the value axis. Defaults to a compact number, such as 12K. */\n formatTick?: (value: number) => string;\n /** Formats a date in the tooltip and announcements. */\n formatDate?: (date: Date) => string;\n /** Events drawn as markers on both charts. */\n annotations?: BrushChartAnnotation[];\n /** Controlled window as [start, end] epoch milliseconds. A new value glides the window there. */\n range?: [number, number];\n /** Initial window when uncontrolled. Defaults to the whole series. */\n defaultRange?: [number, number];\n /** Called while the window is dragged, resized, stepped, or reset. */\n onRangeChange?: (range: [number, number]) => void;\n /** Smallest window in milliseconds. Defaults to seven days. */\n minSpan?: number;\n /** Main plot height in pixels. */\n height?: number;\n /** Overview strip height in pixels. */\n overviewHeight?: number;\n emptyLabel?: string;\n ref?: Ref;\n className?: string;\n}\n\ntype Point = { t: number; value: number };\ntype Drag = { mode: \"start\" | \"end\" | \"pan\" | \"new\"; pointerX: number; from: [number, number]; anchor: number; moved: boolean };\n\nconst DAY = 86_400_000;\nconst { spring } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .001) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst settle = physical(spring.smooth);\nconst glide = physical(spring.snappy, .01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };\nconst draw = physical({ visualDuration: .9, bounce: 0 });\nconst TOP = 28, GAP = 14, GRIP = 14;\nconst compact = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst utc = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", ...options });\nconst fullDate = utc({ weekday: \"short\", month: \"short\", day: \"numeric\", year: \"numeric\" });\nconst dayTick = utc({ month: \"short\", day: \"numeric\" });\nconst monthTick = utc({ month: \"short\" });\nconst yearTick = utc({ year: \"numeric\" });\nconst shortDate = utc({ month: \"short\", day: \"numeric\", year: \"numeric\" });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst time = (date: Date | number) => typeof date === \"number\" ? date : date.getTime();\nconst clean = (value: number) => Number(value.toPrecision(12));\n\n/** Clean gridlines from zero: the smallest step of 1, 2, 2.5, or 5 that covers the data in four rows or fewer. */\nfunction niceTop(high: number) {\n const hi = Math.max(high, 1), magnitude = 10 ** Math.floor(Math.log10(hi / 4));\n for (const factor of [1, 2, 2.5, 5, 10]) { const step = factor * magnitude, top = Math.ceil(hi / step) * step; if (top / step <= 4 + 1e-9) return { top: clean(top), step: clean(step) }; }\n return { top: hi, step: hi / 4 };\n}\n\n/** Calendar ticks that fit the width: days, weeks, then months, quarters, and years, each landing on a real boundary. */\nfunction timeTicks(start: number, end: number, width: number) {\n const room = Math.max(2, Math.floor(width / 76)), span = end - start;\n for (const days of [1, 2, 7, 14]) {\n if (span / (days * DAY) > room) continue;\n const ticks: { t: number; label: string }[] = [];\n let t = Math.ceil(start / DAY) * DAY;\n if (days >= 7) while (new Date(t).getUTCDay() !== 1) t += DAY;\n for (; t <= end; t += days * DAY) ticks.push({ t, label: dayTick.format(t) });\n return ticks;\n }\n for (const months of [1, 2, 3, 6, 12]) {\n if (span / (months * 30.44 * DAY) > room) continue;\n const ticks: { t: number; label: string }[] = [];\n const first = new Date(start);\n let y = first.getUTCFullYear(), m = first.getUTCMonth() + (first.getUTCDate() > 1 || first.getUTCHours() > 0 ? 1 : 0);\n for (;;) {\n y += Math.floor(m / 12); m %= 12;\n const t = Date.UTC(y, m, 1);\n if (t > end) break;\n if (m % months === 0) ticks.push({ t, label: m === 0 ? yearTick.format(t) : monthTick.format(t) });\n m += 1;\n }\n return ticks;\n }\n const ticks: { t: number; label: string }[] = [];\n for (let y = new Date(start).getUTCFullYear() + 1; Date.UTC(y, 0, 1) <= end; y++) ticks.push({ t: Date.UTC(y, 0, 1), label: String(y) });\n return ticks;\n}\n\n/** First index at or after t. */\nfunction lowerBound(points: Point[], t: number) {\n let low = 0, high = points.length;\n while (low < high) { const middle = (low + high) >> 1; if (points[middle].t < t) low = middle + 1; else high = middle; }\n return low;\n}\n\n/** A line through the points in view. Past two points per pixel, each pixel column keeps only its low and high so spikes survive. */\nfunction linePath(points: Point[], from: number, to: number, x: (t: number) => number, y: (value: number) => number, width: number) {\n const slice = points.slice(Math.max(0, from), Math.min(points.length, to));\n if (!slice.length) return \"\";\n const parts: string[] = [];\n if (slice.length <= width * 2) { for (const point of slice) parts.push(`${x(point.t).toFixed(1)},${y(point.value).toFixed(1)}`); }\n else {\n let column = -1, low: Point | null = null, high: Point | null = null;\n const flush = () => { if (!low || !high) return; const pair = low.t < high.t ? [low, high] : [high, low]; for (const point of pair) parts.push(`${x(point.t).toFixed(1)},${y(point.value).toFixed(1)}`); };\n for (const point of slice) {\n const at = Math.floor(x(point.t));\n if (at !== column) { flush(); column = at; low = high = point; continue; }\n if (point.value < low!.value) low = point;\n if (point.value > high!.value) high = point;\n }\n flush();\n }\n return `M${parts.join(\"L\")}`;\n}\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\nfunction useWidth() {\n const node = useRef(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = node.current;\n if (!element) return;\n setWidth(element.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => setWidth(element.clientWidth));\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n return [node, width] as const;\n}\n\nexport function BrushChart({ data, label, unit = \"\", formatValue = value => grouped.format(value), formatTick = value => compact.format(value), formatDate = date => fullDate.format(date), annotations = [], range, defaultRange, onRangeChange, minSpan = 7 * DAY, height = 240, overviewHeight = 52, emptyLabel = \"No data yet\", ref, className }: BrushChartProps) {\n const reduced = useReducedMotionSafe();\n const uid = `brush${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n const figure = useRef(null);\n const tip = useRef(null);\n useImperativeHandle(ref, () => figure.current as HTMLElement);\n const inView = useInView(figure, { once: true, amount: .3 });\n const [plot, width] = useWidth();\n const [strip, stripWidth] = useWidth();\n\n const points = useMemo(() => data.map(item => ({ t: time(item.date), value: item.value })), [data]);\n // Zoomed out, a centred seven point average and its low to high band replace the raw line, so weekly noise reads as texture instead of a solid block.\n const smooth = useMemo(() => {\n const around = (index: number, read: (at: number) => number[]) => { const out: number[][] = []; for (let at = Math.max(0, index - 3); at <= Math.min(points.length - 1, index + 3); at++) out.push(read(at)); return out; };\n const raw = points.map((_, index) => { const values = around(index, at => [points[at].value]).map(([value]) => value); return { value: values.reduce((sum, value) => sum + value, 0) / values.length, low: Math.min(...values), high: Math.max(...values) }; });\n // The band is averaged once more so its edges ease instead of stepping.\n return points.map((point, index) => { const near = around(index, at => [raw[at].low, raw[at].high]); return { t: point.t, value: raw[index].value, low: near.reduce((sum, [low]) => sum + low, 0) / near.length, high: near.reduce((sum, [, high]) => sum + high, 0) / near.length }; });\n }, [points]);\n const events = useMemo(() => annotations.map(item => ({ ...item, t: time(item.date) })).sort((a, b) => a.t - b.t), [annotations]);\n const empty = points.length < 2;\n const first = points[0]?.t ?? 0, lastT = points[points.length - 1]?.t ?? 1;\n const whole: [number, number] = [first, lastT];\n const fit = ([start, end]: [number, number]): [number, number] => {\n const span = clamp(end - start, Math.min(minSpan, lastT - first), lastT - first);\n const s = clamp(start, first, lastT - span);\n return [s, s + span];\n };\n\n // The window: follows the finger 1:1 while dragged, glides on a spring when set from outside or reset.\n const [view, setView] = useState<[number, number]>(() => range ?? defaultRange ?? whole);\n const viewRef = useRef(view);\n const flight = useRef<{ stop: () => void } | null>(null);\n const place = (next: [number, number]) => { viewRef.current = next; setView(next); };\n const glideTo = (next: [number, number]) => {\n flight.current?.stop();\n const from = viewRef.current;\n if (reduced) { place(next); return; }\n flight.current = animate(0, 1, { ...settle, onUpdate: t => place([from[0] + (next[0] - from[0]) * t, from[1] + (next[1] - from[1]) * t]) });\n };\n const commit = (next: [number, number], smooth: boolean) => {\n const target = fit(next);\n if (smooth) glideTo(target); else { flight.current?.stop(); place(target); }\n onRangeChange?.(target);\n };\n const rangeKey = range ? `${range[0]}:${range[1]}` : \"\";\n useEffect(() => {\n if (!range || empty) return;\n const target = fit(range), now = viewRef.current;\n if (Math.abs(target[0] - now[0]) + Math.abs(target[1] - now[1]) > 1000) glideTo(target);\n }, [rangeKey, empty]); // eslint-disable-line react-hooks/exhaustive-deps\n // Data that no longer covers the window pulls it back inside.\n useEffect(() => { if (!empty) { const target = fit(viewRef.current); if (target[0] !== viewRef.current[0] || target[1] !== viewRef.current[1]) place(target); } }, [first, lastT]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const [start, end] = view;\n const span = Math.max(1, end - start);\n const from = Math.max(0, lowerBound(points, start) - 1), to = Math.min(points.length, lowerBound(points, end) + 1);\n let peak = 0;\n for (let index = from; index < to; index++) peak = Math.max(peak, points[index].value);\n const scale = niceTop(peak * 1.04);\n\n // The value axis springs to the tallest point in view, so zooming into a quiet stretch fills the plot.\n const [top, setTop] = useState(scale.top);\n const topFlight = useRef<{ stop: () => void } | null>(null);\n const aimed = useRef(scale.top);\n useEffect(() => {\n if (aimed.current === scale.top) return;\n aimed.current = scale.top;\n topFlight.current?.stop();\n if (reduced) return;\n topFlight.current = animate(top, scale.top, { ...settle, onUpdate: setTop });\n }, [reduced, scale.top]); // eslint-disable-line react-hooks/exhaustive-deps\n useEffect(() => () => { flight.current?.stop(); topFlight.current?.stop(); }, []);\n\n const plotH = height;\n const x = (t: number) => ((t - start) / span) * width;\n const topNow = reduced ? scale.top : top;\n const y = (value: number) => TOP + (1 - value / (topNow || 1)) * (plotH - TOP);\n const line = width && !empty ? linePath(points, from, to, x, y, width) : \"\";\n const area = line ? `${line}L${x(points[to - 1].t).toFixed(1)},${plotH}L${x(points[from].t).toFixed(1)},${plotH}Z` : \"\";\n const trend = width && !empty ? linePath(smooth, from, to, x, y, width * 4) : \"\";\n const band = trend ? `${linePath(smooth.map(point => ({ t: point.t, value: point.high })), from, to, x, y, width * 4)}L${linePath(smooth.map(point => ({ t: point.t, value: point.low })), from, to, x, y, width * 4).slice(1).split(\"L\").reverse().join(\"L\")}Z` : \"\";\n // How much of the raw line shows: all of it past about four pixels a point, none below about three.\n const pxPerPoint = empty ? width : (width * (lastT - first)) / (points.length - 1) / span;\n const detail = clamp((pxPerPoint - 2.75) / 1.5, 0, 1);\n const ticks = scale.top > 0 ? Array.from({ length: Math.round(scale.top / scale.step) + 1 }, (_, index) => clean(index * scale.step)) : [];\n const xTicks = width && !empty ? timeTicks(start, end, width) : [];\n\n // Overview: the whole series, drawn once per width.\n const ox = (t: number) => ((t - first) / (lastT - first || 1)) * stripWidth;\n const overview = useMemo(() => {\n if (!stripWidth || empty) return { line: \"\", area: \"\", marks: [] };\n const max = niceTop(smooth.reduce((m, point) => Math.max(m, point.value), 0)).top || 1;\n const oy = (value: number) => 4 + (1 - value / max) * (overviewHeight - 4);\n const path = linePath(smooth, 0, smooth.length, ox, oy, stripWidth * 4);\n const marks = events.map(event => { const at = clamp(lowerBound(smooth, event.t), 0, smooth.length - 1); return { t: event.t, x: ox(event.t), y: oy(smooth[at].value) }; });\n return { line: path, area: `${path}L${stripWidth},${overviewHeight}L0,${overviewHeight}Z`, marks };\n }, [smooth, events, stripWidth, overviewHeight, empty]); // eslint-disable-line react-hooks/exhaustive-deps\n const wx0 = ox(start), wx1 = ox(end);\n\n // The line draws in from the left once, the first time it is seen.\n const drawn = useMotionValue(0);\n const [reveal, setReveal] = useState(0);\n useEffect(() => {\n if (empty) return;\n if (reduced) { drawn.jump(1); return; }\n if (!inView || drawn.get() >= 1) return;\n const controls = animate(drawn, 1, { ...draw, onUpdate: setReveal });\n return () => controls.stop();\n }, [drawn, empty, inView, reduced]);\n\n // Crosshair on the nearest point in view; the tooltip glides beside it and never leaves the plot.\n const [active, setActive] = useState(null);\n const [hoverEvent, setHoverEvent] = useState(null);\n const index = active !== null && active >= from && active < to ? active : null;\n const nearest = (clientX: number) => {\n const rect = plot.current?.getBoundingClientRect();\n if (!rect?.width || empty) return null;\n const t = start + ((clientX - rect.left) / rect.width) * span;\n const at = clamp(lowerBound(points, t), 1, points.length - 1);\n return clamp(t - points[at - 1].t < points[at].t - t ? at - 1 : at, Math.max(0, from), to - 1);\n };\n const cx = index === null ? 0 : x(points[index].t), cy = index === null ? 0 : y(smooth[index].value + (points[index].value - smooth[index].value) * detail);\n const eventAt = index === null ? null : events.find(event => Math.abs(event.t - points[index].t) < DAY / 2) ?? null;\n const shownEvent = hoverEvent !== null ? events[hoverEvent] : null;\n const tipX = useMotionValue(0), tipY = useMotionValue(0);\n const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);\n const tipOn = index !== null || shownEvent !== null;\n const wasOn = useRef(false);\n useLayoutEffect(() => {\n const bubble = tip.current;\n if (!tipOn || !bubble || !width) { wasOn.current = false; return; }\n const ax = shownEvent ? x(shownEvent.t) : cx, ay = shownEvent ? TOP : cy;\n const tw = bubble.offsetWidth, th = bubble.offsetHeight;\n let left = ax + GAP;\n if (left + tw > width) left = ax - GAP - tw;\n let topY = ay - th - GAP;\n if (topY < 0) topY = ay + GAP;\n tipX.set(clamp(left, 0, Math.max(0, width - tw))); tipY.set(clamp(topY, 0, Math.max(0, plotH - th)));\n if (!wasOn.current || reduced) { tipSpringX.jump(tipX.get()); tipSpringY.jump(tipY.get()); }\n wasOn.current = true;\n });\n\n const onPlotMove = (event: PointerEvent) => { if (event.pointerType === \"mouse\" || event.buttons) setActive(nearest(event.clientX)); };\n const onPlotKey = (event: KeyboardEvent) => {\n if (empty) return;\n if (event.key === \"Escape\" && index !== null) { event.preventDefault(); setActive(null); return; }\n const current = index ?? to - 1, page = Math.max(1, Math.round((to - from) / 8));\n const next = ({ ArrowLeft: current - 1, ArrowRight: index === null ? to - 1 : current + 1, PageDown: current - page, PageUp: current + page, Home: from, End: to - 1 } as Record)[event.key];\n if (next === undefined) return;\n event.preventDefault();\n setActive(clamp(next, Math.max(0, from), to - 1));\n };\n\n // Overview gestures: grab an edge to resize, the window to pan, or empty track to draw a new window. A tap recentres; a double tap resets.\n const drag = useRef(null);\n const toTime = (clientX: number) => { const rect = strip.current?.getBoundingClientRect(); return rect?.width ? first + clamp((clientX - rect.left) / rect.width, 0, 1) * (lastT - first) : first; };\n const onStripDown = (event: PointerEvent) => {\n if (empty || event.button > 0) return;\n const rect = strip.current!.getBoundingClientRect(), px = event.clientX - rect.left;\n const mode: Drag[\"mode\"] = Math.abs(px - wx0) <= GRIP && px <= (wx0 + wx1) / 2 ? \"start\" : Math.abs(px - wx1) <= GRIP ? \"end\" : px > wx0 && px < wx1 ? \"pan\" : \"new\";\n drag.current = { mode, pointerX: event.clientX, from: viewRef.current, anchor: toTime(event.clientX), moved: false };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n flight.current?.stop();\n };\n const onStripMove = (event: PointerEvent) => {\n const current = drag.current;\n if (!current) return;\n if (!current.moved && Math.abs(event.clientX - current.pointerX) < 3) return;\n current.moved = true;\n const t = toTime(event.clientX), shift = t - current.anchor;\n const [s, e] = current.from, least = Math.min(minSpan, lastT - first);\n const next: [number, number] = current.mode === \"pan\" ? [s + shift, e + shift]\n : current.mode === \"start\" ? [clamp(t, first, e - least), e]\n : current.mode === \"end\" ? [s, clamp(t, s + least, lastT)]\n : [Math.min(current.anchor, t), Math.max(current.anchor, t)];\n if (current.mode === \"new\" && next[1] - next[0] < least) { if (t >= current.anchor) next[1] = next[0] + least; else next[0] = next[1] - least; }\n commit(next, false);\n };\n const onStripUp = () => {\n const current = drag.current;\n drag.current = null;\n if (!current || current.moved || current.mode !== \"new\") return;\n const half = (viewRef.current[1] - viewRef.current[0]) / 2;\n commit([current.anchor - half, current.anchor + half], true);\n };\n const reset = () => commit(whole, true);\n\n const stepSize = Math.max(DAY, (lastT - first) / Math.max(1, points.length - 1));\n const onHandleKey = (event: KeyboardEvent, edge: \"start\" | \"end\" | \"window\") => {\n const [s, e] = viewRef.current, win = e - s;\n const big = event.shiftKey || event.key.startsWith(\"Page\");\n const delta = ({ ArrowLeft: -1, ArrowDown: -1, PageDown: -1, ArrowRight: 1, ArrowUp: 1, PageUp: 1 } as Record)[event.key];\n let next: [number, number] | null = null;\n if (event.key === \"Escape\" || event.key === \"0\") next = whole;\n else if (edge === \"window\" && (event.key === \"+\" || event.key === \"=\")) next = [s + win / 4, e - win / 4];\n else if (edge === \"window\" && (event.key === \"-\" || event.key === \"_\")) next = [s - win / 2, e + win / 2];\n else if (event.key === \"Home\") next = edge === \"end\" ? [s, s + Math.min(minSpan, lastT - first)] : [first, edge === \"window\" ? first + win : e];\n else if (event.key === \"End\") next = edge === \"start\" ? [e - Math.min(minSpan, lastT - first), e] : [edge === \"window\" ? lastT - win : s, lastT];\n else if (delta) {\n const move = delta * (edge === \"window\" ? win * (big ? .5 : .1) : stepSize * (big ? 10 : 1));\n next = edge === \"window\" ? [s + move, e + move] : edge === \"start\" ? [Math.min(s + move, e - minSpan), e] : [s, Math.max(e + move, s + minSpan)];\n }\n if (!next) return;\n event.preventDefault();\n commit(next, false);\n };\n\n const format = (value: number) => `${formatValue(value)}${unit ? ` ${unit}` : \"\"}`;\n const reading = index === null ? null : points[index];\n const previous = index !== null && index >= 7 ? points[index - 7] : null;\n const change = reading && previous && previous.value ? (reading.value - previous.value) / previous.value : null;\n const windowText = `${shortDate.format(start)} to ${shortDate.format(end)}`;\n const inWindow = events.filter(event => event.t >= start && event.t <= end);\n // Event labels are placed left to right and skipped when they would collide with the one before.\n const labelled = new Map();\n let edge = -Infinity;\n inWindow.forEach((event, at) => {\n const px = x(event.t), w = event.label.length * 6.2 + 4, next = at + 1 < inWindow.length ? x(inWindow[at + 1].t) - 10 : width;\n if (px + 14 >= edge && px + 14 + w <= next) { labelled.set(event.t, false); edge = px + 14 + w + 8; return; }\n if (px - 14 - w >= Math.max(0, edge) && (at === 0 || px - 14 - w >= x(inWindow[at - 1].t) + 12)) { labelled.set(event.t, true); edge = px + 12; return; }\n edge = Math.max(edge, px + 12);\n });\n const announce = reading ? `${formatDate(new Date(reading.t))}, ${format(reading.value)}${eventAt ? `. ${eventAt.label}` : \"\"}` : \"\";\n const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${shortDate.format(first)} to ${shortDate.format(lastT)}. Showing ${windowText}.`;\n\n return
\n
\n
{ if (event.pointerType !== \"mouse\") { event.currentTarget.setPointerCapture?.(event.pointerId); setActive(nearest(event.clientX)); } }}\n onPointerUp={event => { if (event.pointerType !== \"mouse\") setActive(null); }} onPointerCancel={() => setActive(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setActive(null); }}\n onKeyDown={onPlotKey} onBlur={() => setActive(null)} onDoubleClick={reset} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && !empty) setActive(current => current ?? to - 1); }}>\n \n \n {ticks.slice(1).map(value => )}\n \n {inWindow.map(event => )}\n \n \n \n \n \n \n \n \n \n {inWindow.map(event => { const px = x(event.t), i = events.indexOf(event); return setHoverEvent(i)} onPointerLeave={() => setHoverEvent(null)} onPointerDown={event => event.stopPropagation()}>\n \n {event.label}\n ; })}\n \n {shownEvent ? <>\n

{formatDate(new Date(shownEvent.t))}

\n

{shownEvent.label}

\n {shownEvent.description &&

{shownEvent.description}

}\n : reading ? <>\n

{formatDate(new Date(reading.t))}

\n

{format(reading.value)}

\n {detail < 1 && index !== null &&

{`${formatValue(Math.round(smooth[index].value))} seven day average`}

}\n {change !== null &&

{`${change >= 0 ? \"+\" : \"\\u2212\"}${Math.abs(change * 100).toFixed(1)}% vs a week earlier`}

}\n {eventAt &&

{eventAt.label}

}\n : null}\n
\n {empty &&

{emptyLabel}

}\n
\n
\n {ticks.map(value => {formatTick(value)})}\n
\n
\n {xTicks.map(tick => { const px = x(tick.t); return width - 24 ? -100 : -50}% 0` }}>{tick.label}; })}\n
\n
{ drag.current = null; }} onDoubleClick={reset}>\n \n \n \n \n \n {overview.marks.map(mark => )}\n \n {!empty && <>\n
\n
\n
onHandleKey(event, \"window\")} />\n
onHandleKey(event, \"start\")}>
\n
onHandleKey(event, \"end\")}>
\n }\n
\n
\n

{announce}

\n

{summary}

\n {!empty &&
\n \n \n {points.slice(lowerBound(points, start), lowerBound(points, end + 1)).map(point => { const event = events.find(item => Math.abs(item.t - point.t) < DAY / 2); return ; })}\n
{`${label}, ${windowText}`}
Date{label}Event
{shortDate.format(point.t)}{format(point.value)}{event ? `${event.label}${event.description ? `. ${event.description}` : \"\"}` : \"\"}
}\n
;\n}\n\nexport default BrushChart;\n"},{"path":"registry/components/brush-chart/brush-chart.module.css","type":"registry:component","target":"@components/arc/brush-chart/brush-chart.module.css","content":".figure { display: grid; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n\n/* Plot with a right-hand value axis, the date axis beneath, and the overview strip under both. */\n.chart { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; grid-template-rows: auto 28px auto; row-gap: 0; min-width: 0; }\n.plot { position: relative; grid-area: 1 / 1; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: crosshair; }\n.svg { display: block; overflow: visible; }\n.grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }\n.baseline { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }\n.line { fill: none; stroke: var(--series-1); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }\n.area { fill: color-mix(in oklab, var(--series-1) 10%, transparent); stroke: none; }\n.trend { fill: none; stroke: var(--series-1); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }\n.band { fill: color-mix(in oklab, var(--series-1) 14%, transparent); stroke: none; }\n.crosshair { stroke: var(--foreground); stroke-opacity: .45; stroke-width: 1; shape-rendering: crispEdges; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); pointer-events: none; }\n.dot { fill: var(--series-1); stroke: var(--surface); stroke-width: 2; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); pointer-events: none; }\n.chart[data-active] .crosshair, .chart[data-active] .dot { opacity: 1; }\n\n/* Events: a hairline through the plot, a dot in the second chart series on top, and a label when there is room for it. */\n.eventRule { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; transition: stroke var(--duration-fast) var(--ease-standard); }\n.eventRule[data-active] { stroke: var(--series-2); }\n.event { position: absolute; top: 6px; display: flex; box-sizing: border-box; min-height: 24px; align-items: center; gap: 6px; padding: 0 6px 0 0; margin-left: -12px; border: 0; background: transparent; color: var(--text-secondary); cursor: default; font: inherit; font-size: var(--text-xs); line-height: 1; white-space: nowrap; -webkit-tap-highlight-color: transparent; }\n.event[data-flip] { flex-direction: row-reverse; padding: 0 0 0 6px; margin-left: 0; translate: calc(-100% + 12px) 0; }\n.eventDot { display: grid; width: 24px; height: 24px; flex: none; place-items: center; }\n.eventDot::before { width: 7px; height: 7px; border-radius: 50%; background: var(--series-2); box-shadow: 0 0 0 2px var(--surface); content: \"\"; transition: transform var(--duration-fast) var(--ease-standard); }\n.event[data-active] .eventDot::before { transform: scale(1.3); }\n.eventLabel { opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n.event[data-labelled] .eventLabel { opacity: 1; }\n.event[data-active] .eventLabel { color: var(--foreground); }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 132px; max-width: min(220px, 100%); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.chart[data-active] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }\n.tipValue { margin: 0; color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.tipNote { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.tipEvent { display: flex; align-items: center; gap: 6px; margin: 4px 0 0; color: var(--foreground); font-size: var(--text-xs); line-height: var(--leading-body); }\n.tipEventDot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--series-2); }\n\n.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }\n\n.gutter { position: relative; grid-area: 1 / 2; min-width: 0; }\n.tick { position: absolute; left: 10px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; translate: 0 -50%; pointer-events: none; }\n.axis { position: relative; grid-area: 2 / 1; min-width: 0; }\n.axisLabel { position: absolute; top: 9px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }\n\n/* Overview: the whole history, with everything outside the window stepped back. */\n.strip { --strip-radius: 10px; position: relative; grid-area: 3 / 1; min-width: 0; margin-top: 8px; border-radius: var(--strip-radius); background: color-mix(in oklab, var(--foreground) 2.5%, transparent); cursor: crosshair; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n/* The overview drawing is clipped to the strip's corners; the handles stay outside the clip so they can overhang the ends. */\n.strip > .svg { clip-path: inset(0 round var(--strip-radius)); }\n.overviewLine { fill: none; stroke: color-mix(in oklab, var(--foreground) 28%, var(--surface)); stroke-width: 1; stroke-linejoin: round; }\n.overviewArea { fill: color-mix(in oklab, var(--foreground) 4%, transparent); }\n.overviewLineIn { fill: none; stroke: var(--series-1); stroke-width: 1; stroke-linejoin: round; }\n.overviewAreaIn { fill: color-mix(in oklab, var(--series-1) 12%, transparent); }\n.overviewEvent { fill: var(--series-2); stroke: var(--surface); stroke-width: 1.5; }\n.shade { position: absolute; top: 0; bottom: 0; background: color-mix(in oklab, var(--surface) 45%, transparent); pointer-events: none; }\n.shade[data-edge=\"start\"] { border-radius: var(--strip-radius) 0 0 var(--strip-radius); }\n.shade[data-edge=\"end\"] { border-radius: 0 var(--strip-radius) var(--strip-radius) 0; }\n.window { position: absolute; top: 0; bottom: 0; border-radius: var(--strip-radius); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--series-1) 45%, transparent); cursor: grab; outline: none; transition: box-shadow var(--duration-fast) var(--ease-standard); }\n.window:active { cursor: grabbing; }\n.handle { position: absolute; top: 50%; display: grid; width: 28px; height: 44px; place-items: center; margin-left: -14px; translate: 0 -50%; cursor: ew-resize; outline: none; touch-action: none; }\n.grip { width: 6px; height: 22px; border-radius: 3px; background: var(--surface-raised); box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 26%, transparent), var(--shadow-resting); transition: transform var(--duration-instant) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.handle:active .grip { transform: scaleY(1.12); }\n@media (hover: hover) and (pointer: fine) { .handle:hover .grip { transform: scaleY(1.12); } }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .crosshair, .dot, .tooltip, .eventRule, .eventLabel, .eventDot::before, .grip, .window { transition: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/brush-chart","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/brush-chart","markdown":"https://uiarc.dev/components/brush-chart/markdown","tags":["data","chart","new"]}}