{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/brush-chart/brush-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent,\n  type PointerEvent,\n  type Ref,\n} from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\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<HTMLElement>;\n  className?: string;\n}\n\ntype Point = { t: number; value: number };\ntype Drag = {\n  mode: \"start\" | \"end\" | \"pan\" | \"new\";\n  pointerX: number;\n  from: [number, number];\n  anchor: number;\n  moved: boolean;\n};\n\nconst DAY = 86_400_000;\nconst { spring } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = 0.001) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta,\n    restSpeed: restDelta * 2,\n  };\n};\nconst settle = physical(spring.smooth);\nconst glide = physical(spring.snappy, 0.01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: 0.01 };\nconst draw = physical({ visualDuration: 0.9, bounce: 0 });\nconst TOP = 28,\n  GAP = 14,\n  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),\n    magnitude = 10 ** Math.floor(Math.log10(hi / 4));\n  for (const factor of [1, 2, 2.5, 5, 10]) {\n    const step = factor * magnitude,\n      top = Math.ceil(hi / step) * step;\n    if (top / step <= 4 + 1e-9) return { top: clean(top), step: clean(step) };\n  }\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)),\n    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(),\n      m = first.getUTCMonth() + (first.getUTCDate() > 1 || first.getUTCHours() > 0 ? 1 : 0);\n    for (;;) {\n      y += Math.floor(m / 12);\n      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++)\n    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,\n    high = points.length;\n  while (low < high) {\n    const middle = (low + high) >> 1;\n    if (points[middle]!.t < t) low = middle + 1;\n    else high = middle;\n  }\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(\n  points: Point[],\n  from: number,\n  to: number,\n  x: (t: number) => number,\n  y: (value: number) => number,\n  width: number,\n) {\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) {\n    for (const point of slice) parts.push(`${x(point.t).toFixed(1)},${y(point.value).toFixed(1)}`);\n  } else {\n    let column = -1,\n      low: Point | null = null,\n      high: Point | null = null;\n    const flush = () => {\n      if (!low || !high) return;\n      const pair = low.t < high.t ? [low, high] : [high, low];\n      for (const point of pair) parts.push(`${x(point.t).toFixed(1)},${y(point.value).toFixed(1)}`);\n    };\n    for (const point of slice) {\n      const at = Math.floor(x(point.t));\n      if (at !== column) {\n        flush();\n        column = at;\n        low = high = point;\n        continue;\n      }\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(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nfunction useWidth<T extends HTMLElement>() {\n  const node = useRef<T>(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({\n  data,\n  label,\n  unit = \"\",\n  formatValue = (value) => grouped.format(value),\n  formatTick = (value) => compact.format(value),\n  formatDate = (date) => fullDate.format(date),\n  annotations = [],\n  range,\n  defaultRange,\n  onRangeChange,\n  minSpan = 7 * DAY,\n  height = 240,\n  overviewHeight = 52,\n  emptyLabel = \"No data yet\",\n  ref,\n  className,\n}: BrushChartProps) {\n  const reduced = useReducedMotionSafe();\n  const uid = `brush${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const figure = useRef<HTMLElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: 0.3 });\n  const [plot, width] = useWidth<HTMLDivElement>();\n  const [strip, stripWidth] = useWidth<HTMLDivElement>();\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 = <T,>(index: number, read: (at: number) => T) => {\n      const out: T[] = [];\n      for (let at = Math.max(0, index - 3); at <= Math.min(points.length - 1, index + 3); at++) out.push(read(at));\n      return out;\n    };\n    const raw = points.map((_, index) => {\n      const values = around(index, (at): [number] => [points[at]!.value]).map(([value]) => value);\n      return {\n        value: values.reduce((sum, value) => sum + value, 0) / values.length,\n        low: Math.min(...values),\n        high: Math.max(...values),\n      };\n    });\n    // The band is averaged once more so its edges ease instead of stepping.\n    return points.map((point, index) => {\n      const near = around(index, (at): [number, number] => [raw[at]!.low, raw[at]!.high]);\n      return {\n        t: point.t,\n        value: raw[index]!.value,\n        low: near.reduce((sum, [low]) => sum + low, 0) / near.length,\n        high: near.reduce((sum, [, high]) => sum + high, 0) / near.length,\n      };\n    });\n  }, [points]);\n  const events = useMemo(\n    () => annotations.map((item) => ({ ...item, t: time(item.date) })).sort((a, b) => a.t - b.t),\n    [annotations],\n  );\n  const empty = points.length < 2;\n  const first = points[0]?.t ?? 0,\n    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]) => {\n    viewRef.current = next;\n    setView(next);\n  };\n  const glideTo = (next: [number, number]) => {\n    flight.current?.stop();\n    const from = viewRef.current;\n    if (reduced) {\n      place(next);\n      return;\n    }\n    flight.current = animate(0, 1, {\n      ...settle,\n      onUpdate: (t) => place([from[0] + (next[0] - from[0]) * t, from[1] + (next[1] - from[1]) * t]),\n    });\n  };\n  const commit = (next: [number, number], smooth: boolean) => {\n    const target = fit(next);\n    if (smooth) glideTo(target);\n    else {\n      flight.current?.stop();\n      place(target);\n    }\n    onRangeChange?.(target);\n  };\n  const rangeKey = range ? `${range[0]}:${range[1]}` : \"\";\n  useEffect(() => {\n    if (!range || empty) return;\n    const target = fit(range),\n      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(() => {\n    if (!empty) {\n      const target = fit(viewRef.current);\n      if (target[0] !== viewRef.current[0] || target[1] !== viewRef.current[1]) place(target);\n    }\n  }, [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),\n    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(\n    () => () => {\n      flight.current?.stop();\n      topFlight.current?.stop();\n    },\n    [],\n  );\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\n    ? `${line}L${x(points[to - 1]!.t).toFixed(1)},${plotH}L${x(points[from]!.t).toFixed(1)},${plotH}Z`\n    : \"\";\n  const trend = width && !empty ? linePath(smooth, from, to, x, y, width * 4) : \"\";\n  const band = trend\n    ? `${linePath(\n        smooth.map((point) => ({ t: point.t, value: point.high })),\n        from,\n        to,\n        x,\n        y,\n        width * 4,\n      )}L${linePath(\n        smooth.map((point) => ({ t: point.t, value: point.low })),\n        from,\n        to,\n        x,\n        y,\n        width * 4,\n      )\n        .slice(1)\n        .split(\"L\")\n        .reverse()\n        .join(\"L\")}Z`\n    : \"\";\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 =\n    scale.top > 0\n      ? Array.from({ length: Math.round(scale.top / scale.step) + 1 }, (_, index) => clean(index * scale.step))\n      : [];\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) => {\n      const at = clamp(lowerBound(smooth, event.t), 0, smooth.length - 1);\n      return { t: event.t, x: ox(event.t), y: oy(smooth[at]!.value) };\n    });\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),\n    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) {\n      drawn.jump(1);\n      return;\n    }\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<number | null>(null);\n  const [hoverEvent, setHoverEvent] = useState<number | null>(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),\n    cy = index === null ? 0 : y(smooth[index]!.value + (points[index]!.value - smooth[index]!.value) * detail);\n  const eventAt =\n    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),\n    tipY = useMotionValue(0);\n  const tipSpringX = useSpring(tipX, follow),\n    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) {\n      wasOn.current = false;\n      return;\n    }\n    const ax = shownEvent ? x(shownEvent.t) : cx,\n      ay = shownEvent ? TOP : cy;\n    const tw = bubble.offsetWidth,\n      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)));\n    tipY.set(clamp(topY, 0, Math.max(0, plotH - th)));\n    if (!wasOn.current || reduced) {\n      tipSpringX.jump(tipX.get());\n      tipSpringY.jump(tipY.get());\n    }\n    wasOn.current = true;\n  });\n\n  const onPlotMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" || event.buttons) setActive(nearest(event.clientX));\n  };\n  const onPlotKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (empty) return;\n    if (event.key === \"Escape\" && index !== null) {\n      event.preventDefault();\n      setActive(null);\n      return;\n    }\n    const current = index ?? to - 1,\n      page = Math.max(1, Math.round((to - from) / 8));\n    const next = (\n      {\n        ArrowLeft: current - 1,\n        ArrowRight: index === null ? to - 1 : current + 1,\n        PageDown: current - page,\n        PageUp: current + page,\n        Home: from,\n        End: to - 1,\n      } as Record<string, number>\n    )[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<Drag | null>(null);\n  const toTime = (clientX: number) => {\n    const rect = strip.current?.getBoundingClientRect();\n    return rect?.width ? first + clamp((clientX - rect.left) / rect.width, 0, 1) * (lastT - first) : first;\n  };\n  const onStripDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (empty || event.button > 0) return;\n    const rect = strip.current!.getBoundingClientRect(),\n      px = event.clientX - rect.left;\n    const mode: Drag[\"mode\"] =\n      Math.abs(px - wx0) <= GRIP && px <= (wx0 + wx1) / 2\n        ? \"start\"\n        : Math.abs(px - wx1) <= GRIP\n          ? \"end\"\n          : px > wx0 && px < wx1\n            ? \"pan\"\n            : \"new\";\n    drag.current = {\n      mode,\n      pointerX: event.clientX,\n      from: viewRef.current,\n      anchor: toTime(event.clientX),\n      moved: false,\n    };\n    event.currentTarget.setPointerCapture?.(event.pointerId);\n    flight.current?.stop();\n  };\n  const onStripMove = (event: PointerEvent<HTMLDivElement>) => {\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),\n      shift = t - current.anchor;\n    const [s, e] = current.from,\n      least = Math.min(minSpan, lastT - first);\n    const next: [number, number] =\n      current.mode === \"pan\"\n        ? [s + shift, e + shift]\n        : current.mode === \"start\"\n          ? [clamp(t, first, e - least), e]\n          : current.mode === \"end\"\n            ? [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) {\n      if (t >= current.anchor) next[1] = next[0] + least;\n      else next[0] = next[1] - least;\n    }\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<HTMLDivElement>, edge: \"start\" | \"end\" | \"window\") => {\n    const [s, e] = viewRef.current,\n      win = e - s;\n    const big = event.shiftKey || event.key.startsWith(\"Page\");\n    const delta = (\n      { ArrowLeft: -1, ArrowDown: -1, PageDown: -1, ArrowRight: 1, ArrowUp: 1, PageUp: 1 } as Record<string, number>\n    )[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\")\n      next = edge === \"end\" ? [s, s + Math.min(minSpan, lastT - first)] : [first, edge === \"window\" ? first + win : e];\n    else if (event.key === \"End\")\n      next =\n        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 ? 0.5 : 0.1) : stepSize * (big ? 10 : 1));\n      next =\n        edge === \"window\"\n          ? [s + move, e + move]\n          : edge === \"start\"\n            ? [Math.min(s + move, e - minSpan), e]\n            : [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<number, boolean>();\n  let edge = -Infinity;\n  inWindow.forEach((event, at) => {\n    const px = x(event.t),\n      w = event.label.length * 6.2 + 4,\n      next = at + 1 < inWindow.length ? x(inWindow[at + 1]!.t) - 10 : width;\n    if (px + 14 >= edge && px + 14 + w <= next) {\n      labelled.set(event.t, false);\n      edge = px + 14 + w + 8;\n      return;\n    }\n    if (px - 14 - w >= Math.max(0, edge) && (at === 0 || px - 14 - w >= x(inWindow[at - 1]!.t) + 12)) {\n      labelled.set(event.t, true);\n      edge = px + 12;\n      return;\n    }\n    edge = Math.max(edge, px + 12);\n  });\n  const announce = reading\n    ? `${formatDate(new Date(reading.t))}, ${format(reading.value)}${eventAt ? `. ${eventAt.label}` : \"\"}`\n    : \"\";\n  const summary = empty\n    ? `${label}. ${emptyLabel}.`\n    : `${label}, ${shortDate.format(first)} to ${shortDate.format(lastT)}. Showing ${windowText}.`;\n\n  return (\n    <figure\n      ref={figure}\n      className={cn(\"m-0 grid min-w-0 font-body tracking-body text-ink\", className)}\n      aria-label={label}\n    >\n      {/* Plot with a right-hand value axis, the date axis beneath, and the overview strip under both. Series colors come from the series tokens. */}\n      <div\n        className=\"relative grid min-w-0 grid-cols-[minmax(0,1fr)_44px] grid-rows-[auto_28px_auto] gap-y-0\"\n        data-active={tipOn || undefined}\n      >\n        {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the plot is a focusable chart widget explored by pointer and arrow keys; <fieldset> would add form semantics */}\n        <div\n          ref={plot}\n          className=\"relative min-w-0 cursor-crosshair touch-pan-y outline-none select-none [grid-area:1/1] [-webkit-tap-highlight-color:transparent]\"\n          style={{ height: plotH }}\n          role=\"group\"\n          tabIndex={empty ? -1 : 0}\n          aria-roledescription=\"chart\"\n          aria-label={`${label}, ${windowText}. Use left and right arrows to read values.`}\n          onPointerMove={onPlotMove}\n          onPointerDown={(event) => {\n            if (event.pointerType !== \"mouse\") {\n              event.currentTarget.setPointerCapture?.(event.pointerId);\n              setActive(nearest(event.clientX));\n            }\n          }}\n          onPointerUp={(event) => {\n            if (event.pointerType !== \"mouse\") setActive(null);\n          }}\n          onPointerCancel={() => setActive(null)}\n          onPointerLeave={(event) => {\n            if (event.pointerType === \"mouse\") setActive(null);\n          }}\n          onKeyDown={onPlotKey}\n          onBlur={() => setActive(null)}\n          onDoubleClick={reset}\n          onFocus={(event) => {\n            if (event.currentTarget.matches(\":focus-visible\") && !empty) setActive((current) => current ?? to - 1);\n          }}\n        >\n          <svg className=\"block overflow-visible\" width=\"100%\" height={plotH} aria-hidden=\"true\" focusable=\"false\">\n            <defs>\n              <clipPath id={`${uid}-plot`}>\n                <rect x={0} y={-8} width={Math.max(0, width * (reduced ? 1 : reveal))} height={plotH + 16} />\n              </clipPath>\n            </defs>\n            {ticks.slice(1).map((value) => (\n              <line\n                key={value}\n                className=\"stroke-line [stroke-width:1] [shape-rendering:crispEdges]\"\n                x1={0}\n                x2=\"100%\"\n                y1={Math.round(y(value)) + 0.5}\n                y2={Math.round(y(value)) + 0.5}\n              />\n            ))}\n            <line\n              className=\"stroke-line-strong [stroke-width:1] [shape-rendering:crispEdges]\"\n              x1={0}\n              x2=\"100%\"\n              y1={plotH - 0.5}\n              y2={plotH - 0.5}\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            {inWindow.map((event) => {\n              const lit = shownEvent?.t === event.t || eventAt?.t === event.t;\n              return (\n                <line\n                  key={event.t}\n                  className={cn(\n                    \"[stroke-width:1] [shape-rendering:crispEdges] transition-[stroke] duration-fast ease-standard motion-reduce:transition-none\",\n                    lit ? \"stroke-series-2\" : \"stroke-line-strong\",\n                  )}\n                  x1={Math.round(x(event.t)) + 0.5}\n                  x2={Math.round(x(event.t)) + 0.5}\n                  y1={TOP - 10}\n                  y2={plotH}\n                  data-active={lit || undefined}\n                />\n              );\n            })}\n            <g clipPath={`url(#${uid}-plot)`}>\n              <path\n                className=\"fill-[color-mix(in_oklab,var(--series-1)_14%,transparent)] stroke-none\"\n                d={band}\n                style={{ opacity: 1 - detail }}\n              />\n              <path\n                className=\"fill-[color-mix(in_oklab,var(--series-1)_10%,transparent)] stroke-none\"\n                d={area}\n                style={{ opacity: detail }}\n              />\n              <path\n                className=\"fill-none stroke-series-1 [stroke-width:1.75] [stroke-linecap:round] [stroke-linejoin:round]\"\n                d={trend}\n                style={{ opacity: 1 - detail }}\n              />\n              <path\n                className=\"fill-none stroke-series-1 [stroke-width:1.75] [stroke-linecap:round] [stroke-linejoin:round]\"\n                d={line}\n                style={{ opacity: detail }}\n              />\n            </g>\n            <line\n              className={cn(\n                \"pointer-events-none stroke-ink [stroke-opacity:.45] [stroke-width:1] [shape-rendering:crispEdges] transition-opacity duration-instant ease-standard motion-reduce:transition-none\",\n                tipOn ? \"opacity-100\" : \"opacity-0\",\n              )}\n              x1={Math.round(cx) + 0.5}\n              x2={Math.round(cx) + 0.5}\n              y1={TOP - 10}\n              y2={plotH}\n            />\n            <circle\n              className={cn(\n                \"pointer-events-none fill-series-1 stroke-surface [stroke-width:2] transition-opacity duration-instant ease-standard motion-reduce:transition-none\",\n                tipOn ? \"opacity-100\" : \"opacity-0\",\n              )}\n              cx={cx}\n              cy={cy}\n              r={4}\n            />\n          </svg>\n          {inWindow.map((event) => {\n            const px = x(event.t),\n              i = events.indexOf(event),\n              flip = labelled.get(event.t),\n              lit = shownEvent?.t === event.t || eventAt?.t === event.t;\n            return (\n              <span\n                key={event.t}\n                className={cn(\n                  \"absolute top-[6px] box-border flex min-h-6 cursor-default items-center gap-[6px] border-0 bg-transparent text-(length:--text-xs) leading-none whitespace-nowrap text-ink-2 [-webkit-tap-highlight-color:transparent]\",\n                  flip\n                    ? \"ml-0 translate-x-[calc(-100%+12px)] flex-row-reverse py-0 pr-0 pl-[6px]\"\n                    : \"-ml-3 py-0 pr-[6px] pl-0\",\n                )}\n                style={{ left: px }}\n                data-labelled={labelled.has(event.t) || undefined}\n                data-flip={flip || undefined}\n                data-active={lit || undefined}\n                aria-hidden=\"true\"\n                onPointerEnter={() => setHoverEvent(i)}\n                onPointerLeave={() => setHoverEvent(null)}\n                onPointerDown={(event) => event.stopPropagation()}\n              >\n                <span\n                  className={cn(\n                    \"grid size-6 flex-none place-items-center before:size-[7px] before:rounded-full before:bg-series-2 before:shadow-[0_0_0_2px_var(--surface)] before:content-[''] before:transition-transform before:duration-fast before:ease-standard motion-reduce:before:transition-none\",\n                    lit && \"before:[transform:scale(1.3)]\",\n                  )}\n                />\n                <span\n                  className={cn(\n                    \"[transition:opacity_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n                    labelled.has(event.t) ? \"opacity-100\" : \"opacity-0\",\n                    lit && \"text-ink\",\n                  )}\n                >\n                  {event.label}\n                </span>\n              </span>\n            );\n          })}\n          <motion.div\n            ref={tip}\n            className={cn(\n              \"pointer-events-none absolute top-0 left-0 z-2 grid max-w-[min(220px,100%)] min-w-[132px] gap-0.5 rounded-[14px] border border-line bg-surface px-3 py-[10px] shadow-floating [transition:opacity_var(--duration-fast)_var(--ease-standard),scale_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n              tipOn ? \"opacity-100 [scale:1]\" : \"opacity-0 [scale:.96]\",\n            )}\n            style={{ x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }}\n            aria-hidden=\"true\"\n          >\n            {shownEvent ? (\n              <>\n                <p className=\"m-0 mb-0.5 text-xs leading-body whitespace-nowrap text-ink-2 tabular-nums\">\n                  {formatDate(new Date(shownEvent.t))}\n                </p>\n                <p className=\"m-0 text-base leading-body font-medium text-ink tabular-nums\">{shownEvent.label}</p>\n                {shownEvent.description && (\n                  <p className=\"m-0 text-xs leading-body text-ink-3 tabular-nums\">{shownEvent.description}</p>\n                )}\n              </>\n            ) : reading ? (\n              <>\n                <p className=\"m-0 mb-0.5 text-xs leading-body whitespace-nowrap text-ink-2 tabular-nums\">\n                  {formatDate(new Date(reading.t))}\n                </p>\n                <p className=\"m-0 text-base leading-body font-medium text-ink tabular-nums\">{format(reading.value)}</p>\n                {detail < 1 && index !== null && (\n                  <p className=\"m-0 text-xs leading-body text-ink-3 tabular-nums\">{`${formatValue(Math.round(smooth[index]!.value))} seven day average`}</p>\n                )}\n                {change !== null && (\n                  <p className=\"m-0 text-xs leading-body text-ink-3 tabular-nums\">{`${change >= 0 ? \"+\" : \"\\u2212\"}${Math.abs(change * 100).toFixed(1)}% vs a week earlier`}</p>\n                )}\n                {eventAt && (\n                  <p className=\"mx-0 mt-1 mb-0 flex items-center gap-[6px] text-xs leading-body text-ink\">\n                    <span className=\"size-[7px] flex-none rounded-full bg-series-2\" />\n                    {eventAt.label}\n                  </p>\n                )}\n              </>\n            ) : null}\n          </motion.div>\n          {empty && (\n            <p className=\"pointer-events-none absolute inset-0 m-0 grid place-items-center text-(length:--text-sm) text-ink-3\">\n              {emptyLabel}\n            </p>\n          )}\n        </div>\n        <div className=\"relative min-w-0 [grid-area:1/2]\" aria-hidden=\"true\">\n          {ticks.map((value) => (\n            <span\n              key={value}\n              className=\"pointer-events-none absolute left-[10px] -translate-y-1/2 text-(length:--text-xs) leading-none whitespace-nowrap text-ink-3 tabular-nums\"\n              style={{ top: y(value) }}\n            >\n              {formatTick(value)}\n            </span>\n          ))}\n        </div>\n        <div className=\"relative min-w-0 [grid-area:2/1]\" aria-hidden=\"true\">\n          {xTicks.map((tick) => {\n            const px = x(tick.t);\n            return (\n              <span\n                key={tick.t}\n                className=\"absolute top-[9px] text-(length:--text-xs) leading-none whitespace-nowrap text-ink-3 tabular-nums\"\n                style={{ left: px, translate: `${px < 24 ? 0 : px > width - 24 ? -100 : -50}% 0` }}\n              >\n                {tick.label}\n              </span>\n            );\n          })}\n        </div>\n        {/* Overview: the whole history, with everything outside the window stepped back. */}\n        <div\n          ref={strip}\n          className=\"relative mt-2 min-w-0 cursor-crosshair touch-pan-y rounded-(--strip-radius) bg-[color-mix(in_oklab,var(--ink)_2.5%,transparent)] select-none [--strip-radius:10px] [grid-area:3/1] [-webkit-tap-highlight-color:transparent]\"\n          style={{ height: overviewHeight }}\n          onPointerDown={onStripDown}\n          onPointerMove={onStripMove}\n          onPointerUp={onStripUp}\n          onPointerCancel={() => {\n            drag.current = null;\n          }}\n          onDoubleClick={reset}\n        >\n          {/* The overview drawing is clipped to the strip's corners; the handles stay outside the clip so they can overhang the ends. */}\n          <svg\n            className=\"block overflow-visible [clip-path:inset(0_round_var(--strip-radius))]\"\n            width=\"100%\"\n            height={overviewHeight}\n            aria-hidden=\"true\"\n            focusable=\"false\"\n          >\n            <defs>\n              <clipPath id={`${uid}-window`}>\n                <rect x={wx0} y={-2} width={Math.max(0, wx1 - wx0)} height={overviewHeight + 4} />\n              </clipPath>\n            </defs>\n            <path className=\"fill-[color-mix(in_oklab,var(--ink)_4%,transparent)]\" d={overview.area} />\n            <path\n              className=\"fill-none stroke-[color-mix(in_oklab,var(--ink)_28%,var(--surface))] [stroke-width:1] [stroke-linejoin:round]\"\n              d={overview.line}\n            />\n            <g clipPath={`url(#${uid}-window)`}>\n              <path className=\"fill-[color-mix(in_oklab,var(--series-1)_12%,transparent)]\" d={overview.area} />\n              <path className=\"fill-none stroke-series-1 [stroke-width:1] [stroke-linejoin:round]\" d={overview.line} />\n            </g>\n            {overview.marks.map((mark) => (\n              <circle\n                key={mark.t}\n                className=\"fill-series-2 stroke-surface [stroke-width:1.5]\"\n                cx={mark.x}\n                cy={mark.y}\n                r={2.5}\n              />\n            ))}\n          </svg>\n          {!empty && (\n            <>\n              <div\n                className=\"pointer-events-none absolute top-0 bottom-0 rounded-l-(--strip-radius) bg-[color-mix(in_oklab,var(--surface)_45%,transparent)]\"\n                data-edge=\"start\"\n                style={{ left: 0, width: Math.max(0, wx0) }}\n              />\n              <div\n                className=\"pointer-events-none absolute top-0 bottom-0 rounded-r-(--strip-radius) bg-[color-mix(in_oklab,var(--surface)_45%,transparent)]\"\n                data-edge=\"end\"\n                style={{ left: wx1, right: 0 }}\n              />\n              <div\n                className=\"absolute top-0 bottom-0 cursor-grab rounded-(--strip-radius) shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--series-1)_45%,transparent)] outline-none transition-shadow duration-fast ease-standard active:cursor-grabbing motion-reduce:transition-none\"\n                style={{ left: wx0, width: Math.max(0, wx1 - wx0) }}\n                role=\"slider\"\n                tabIndex={0}\n                aria-label={`${label} window`}\n                aria-roledescription=\"range window\"\n                aria-valuemin={first}\n                aria-valuemax={lastT}\n                aria-valuenow={Math.round(start)}\n                aria-valuetext={windowText}\n                onKeyDown={(event) => onHandleKey(event, \"window\")}\n              />\n              <div\n                className=\"group/handle absolute top-1/2 -ml-[14px] grid h-11 w-7 -translate-y-1/2 cursor-ew-resize touch-none place-items-center outline-none\"\n                style={{ left: wx0 }}\n                role=\"slider\"\n                tabIndex={0}\n                aria-label=\"Window start\"\n                aria-valuemin={first}\n                aria-valuemax={Math.round(end)}\n                aria-valuenow={Math.round(start)}\n                aria-valuetext={shortDate.format(start)}\n                onKeyDown={(event) => onHandleKey(event, \"start\")}\n              >\n                <span className=\"h-[22px] w-[6px] rounded-[3px] bg-surface shadow-[0_0_0_1px_color-mix(in_oklab,var(--ink)_26%,transparent),var(--shadow-resting)] [transition:transform_var(--duration-instant)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] group-hover/handle:[transform:scaleY(1.12)] group-active/handle:[transform:scaleY(1.12)] motion-reduce:transition-none\" />\n              </div>\n              <div\n                className=\"group/handle absolute top-1/2 -ml-[14px] grid h-11 w-7 -translate-y-1/2 cursor-ew-resize touch-none place-items-center outline-none\"\n                style={{ left: wx1 }}\n                role=\"slider\"\n                tabIndex={0}\n                aria-label=\"Window end\"\n                aria-valuemin={Math.round(start)}\n                aria-valuemax={lastT}\n                aria-valuenow={Math.round(end)}\n                aria-valuetext={shortDate.format(end)}\n                onKeyDown={(event) => onHandleKey(event, \"end\")}\n              >\n                <span className=\"h-[22px] w-[6px] rounded-[3px] bg-surface shadow-[0_0_0_1px_color-mix(in_oklab,var(--ink)_26%,transparent),var(--shadow-resting)] [transition:transform_var(--duration-instant)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] group-hover/handle:[transform:scaleY(1.12)] group-active/handle:[transform:scaleY(1.12)] motion-reduce:transition-none\" />\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announce}\n      </p>\n      <p className=\"sr-only\">{summary}</p>\n      {!empty && (\n        <div className=\"sr-only\">\n          <table>\n            <caption>{`${label}, ${windowText}`}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Date</th>\n                <th scope=\"col\">{label}</th>\n                <th scope=\"col\">Event</th>\n              </tr>\n            </thead>\n            <tbody>\n              {points.slice(lowerBound(points, start), lowerBound(points, end + 1)).map((point) => {\n                const event = events.find((item) => Math.abs(item.t - point.t) < DAY / 2);\n                return (\n                  <tr key={point.t}>\n                    <th scope=\"row\">{shortDate.format(point.t)}</th>\n                    <td>{format(point.value)}</td>\n                    <td>{event ? `${event.label}${event.description ? `. ${event.description}` : \"\"}` : \"\"}</td>\n                  </tr>\n                );\n              })}\n            </tbody>\n          </table>\n        </div>\n      )}\n    </figure>\n  );\n}\n\nexport default BrushChart;\n",
      "type": "registry:component",
      "target": "@components/iris/brush-chart/brush-chart.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/brush-chart",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "BrushChart",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://irisui.dev/components/brush-chart"
  }
}
