{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "brush-chart",
  "type": "registry:ui",
  "title": "Brush Chart",
  "description": "A time-series chart with an overview brush: drag or resize the window and the main chart glides to the new range.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/brush-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface BrushChartDatum {\n  /** Time: epoch milliseconds or an ISO 8601 string. */\n  t: number | string;\n  v: number;\n}\n\nexport interface BrushChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: BrushChartDatum[];\n  /** Accessible name of the chart. */\n  label: string;\n  /** Filled area or a plain line. */\n  type?: \"area\" | \"line\";\n  /** Monotone curve (no overshoot) or straight segments. */\n  curve?: \"monotone\" | \"linear\";\n  /** Height of the main chart in px. */\n  height?: number;\n  /** Height of the overview strip in px. */\n  overviewHeight?: number;\n  /** Initial window as a fraction of the full range, anchored to the end. */\n  defaultSpan?: number;\n  /** Initial window as [start, end] in epoch ms (overrides defaultSpan). */\n  defaultRange?: [number, number];\n  /** Domain transition time constant in ms (0 = instant). */\n  duration?: number;\n  /** Horizontal grid lines. */\n  showGrid?: boolean;\n  /** Keep 0 inside the value axis. */\n  includeZero?: boolean;\n  /** Line, area and brush colour. */\n  accent?: string;\n  formatValue?: (value: number) => string;\n  formatTime?: (time: number) => string;\n  /** Called with [start, end] (epoch ms) when the selected window settles. */\n  onRangeChange?: (range: [number, number]) => void;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* ------------------------------- data helpers ------------------------------ */\n\nconst HOUR = 3_600_000;\nconst DAY = 24 * HOUR;\nconst MARGIN = { top: 14, right: 14, bottom: 26, left: 48 };\nconst X_SLOTS = 12;\nconst Y_SLOTS = 8;\n\ninterface Series {\n  ts: number[];\n  vs: number[];\n  /** Median spacing between points, in ms. */\n  step: number;\n}\n\nfunction toSeries(data: BrushChartDatum[]): Series {\n  const points = data\n    .map((d) => ({ t: typeof d.t === \"string\" ? Date.parse(d.t) : d.t, v: d.v }))\n    .filter((d) => Number.isFinite(d.t) && Number.isFinite(d.v))\n    .sort((a, b) => a.t - b.t);\n  const ts = points.map((p) => p.t);\n  const gaps = ts.slice(1).map((t, i) => t - ts[i]).sort((a, b) => a - b);\n  return { ts, vs: points.map((p) => p.v), step: gaps.length ? Math.max(1, gaps[Math.floor(gaps.length / 2)]) : DAY };\n}\n\n/** First index with ts[i] >= t. */\nfunction lowerBound(ts: number[], t: number) {\n  let lo = 0;\n  let hi = ts.length;\n  while (lo < hi) {\n    const mid = (lo + hi) >> 1;\n    if (ts[mid] < t) lo = mid + 1;\n    else hi = mid;\n  }\n  return lo;\n}\n\nfunction nearestIndex(ts: number[], t: number) {\n  const i = lowerBound(ts, t);\n  if (i <= 0) return 0;\n  if (i >= ts.length) return ts.length - 1;\n  return t - ts[i - 1] < ts[i] - t ? i - 1 : i;\n}\n\nfunction niceStep(raw: number) {\n  const exp = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1e-12))));\n  const f = raw / exp;\n  return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;\n}\n\n/** A rounded value domain for the points inside [d0, d1]. */\nfunction valueDomain(s: Series, d0: number, d1: number, includeZero: boolean): [number, number] {\n  const from = Math.max(0, lowerBound(s.ts, d0) - 1);\n  const to = Math.min(s.ts.length, lowerBound(s.ts, d1) + 1);\n  let min = Infinity;\n  let max = -Infinity;\n  for (let i = from; i < to; i++) {\n    min = Math.min(min, s.vs[i]);\n    max = Math.max(max, s.vs[i]);\n  }\n  if (!Number.isFinite(min)) return [0, 1];\n  if (includeZero) {\n    min = Math.min(0, min);\n    max = Math.max(0, max);\n  }\n  if (max - min < 1e-9) {\n    min -= 1;\n    max += 1;\n  }\n  const step = niceStep((max - min) / 4);\n  return [Math.floor(min / step) * step, Math.ceil(max / step) * step];\n}\n\n/* ---------------------------------- paths ---------------------------------- */\n\nconst f1 = (n: number) => n.toFixed(1);\n\n/** Monotone cubic interpolation (Fritsch–Carlson): smooth, never overshoots the data. */\nfunction linePath(xs: number[], ys: number[], curve: \"monotone\" | \"linear\") {\n  const n = xs.length;\n  if (!n) return \"\";\n  let d = `M${f1(xs[0])},${f1(ys[0])}`;\n  if (n === 1) return d;\n  if (curve === \"linear\") {\n    for (let i = 1; i < n; i++) d += `L${f1(xs[i])},${f1(ys[i])}`;\n    return d;\n  }\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) slopes.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i] || 1));\n  const tangents = xs.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    return slopes[i - 1] * slopes[i] <= 0 ? 0 : (slopes[i - 1] + slopes[i]) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    if (slopes[i] === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / slopes[i];\n    const b = tangents[i + 1] / slopes[i];\n    const h = a * a + b * b;\n    if (h > 9) {\n      const tau = 3 / Math.sqrt(h);\n      tangents[i] = tau * a * slopes[i];\n      tangents[i + 1] = tau * b * slopes[i];\n    }\n  }\n  for (let i = 0; i < n - 1; i++) {\n    const dx = (xs[i + 1] - xs[i]) / 3;\n    d += `C${f1(xs[i] + dx)},${f1(ys[i] + tangents[i] * dx)} ${f1(xs[i + 1] - dx)},${f1(ys[i + 1] - tangents[i + 1] * dx)} ${f1(xs[i + 1])},${f1(ys[i + 1])}`;\n  }\n  return d;\n}\n\n/* ---------------------------------- ticks ---------------------------------- */\n\nconst fmtMonthDay = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", month: \"short\", day: \"numeric\" });\nconst fmtMonth = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", month: \"short\" });\nconst fmtYear = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", year: \"numeric\" });\nconst fmtHour = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", hour: \"2-digit\", minute: \"2-digit\", hourCycle: \"h23\" });\nconst fmtFullDay = new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", month: \"short\", day: \"numeric\", year: \"numeric\" });\nconst fmtFullHour = new Intl.DateTimeFormat(\"en-US\", {\n  timeZone: \"UTC\",\n  month: \"short\",\n  day: \"numeric\",\n  hour: \"2-digit\",\n  minute: \"2-digit\",\n  hourCycle: \"h23\",\n});\nconst fmtNumber = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n\ntype Interval = { ms: number; months?: number };\nconst INTERVALS: Interval[] = [\n  { ms: HOUR },\n  { ms: 3 * HOUR },\n  { ms: 6 * HOUR },\n  { ms: 12 * HOUR },\n  { ms: DAY },\n  { ms: 2 * DAY },\n  { ms: 7 * DAY },\n  { ms: 14 * DAY },\n  { ms: 30 * DAY, months: 1 },\n  { ms: 91 * DAY, months: 3 },\n  { ms: 182 * DAY, months: 6 },\n  { ms: 365 * DAY, months: 12 },\n];\n\n/** UTC-aligned time ticks for [d0, d1], about `count` of them, with labels. */\nfunction timeTicks(d0: number, d1: number, count: number): { t: number; label: string }[] {\n  const span = d1 - d0;\n  const interval = INTERVALS.find((i) => span / i.ms <= count) ?? INTERVALS[INTERVALS.length - 1];\n  const out: { t: number; label: string }[] = [];\n  if (interval.months) {\n    const k = interval.months;\n    const start = new Date(d0);\n    let y = start.getUTCFullYear();\n    let m = Math.floor(start.getUTCMonth() / k) * k;\n    for (let guard = 0; guard < 60; guard++) {\n      const t = Date.UTC(y, m, 1);\n      if (t > d1) break;\n      if (t >= d0) out.push({ t, label: m === 0 || k === 12 ? fmtYear.format(t) : fmtMonth.format(t) });\n      m += k;\n      if (m >= 12) {\n        y += Math.floor(m / 12);\n        m %= 12;\n      }\n    }\n    return out;\n  }\n  // Weeks start on Monday (the epoch was a Thursday).\n  const offset = interval.ms >= 7 * DAY ? 4 * DAY : 0;\n  let t = Math.ceil((d0 - offset) / interval.ms) * interval.ms + offset;\n  for (let guard = 0; t <= d1 && guard < 60; guard++, t += interval.ms) {\n    const label = interval.ms < DAY && t % DAY !== 0 ? fmtHour.format(t) : fmtMonthDay.format(t);\n    out.push({ t, label });\n  }\n  return out;\n}\n\nfunction valueTicks(y0: number, y1: number, count: number) {\n  const step = niceStep((y1 - y0) / count);\n  const out: number[] = [];\n  for (let v = Math.ceil(y0 / step) * step; v <= y1 + step * 1e-6 && out.length < Y_SLOTS; v += step) out.push(Number(v.toFixed(10)));\n  return out;\n}\n\n/* -------------------------------- component -------------------------------- */\n\ninterface Domain {\n  d0: number;\n  d1: number;\n  y0: number;\n  y1: number;\n}\n\n/** Everything the imperative drawing code needs, refreshed after every render. */\ninterface Model {\n  series: Series;\n  full: [number, number];\n  minSpan: number;\n  width: number;\n  plotW: number;\n  plotH: number;\n  ovH: number;\n  type: \"area\" | \"line\";\n  curve: \"monotone\" | \"linear\";\n  includeZero: boolean;\n  tau: number;\n  fmtValue: (v: number) => string;\n  fmtTime: (t: number) => string;\n}\n\ntype BrushDrag = { kind: \"start\" | \"end\" | \"window\"; startX: number; d0: number; d1: number } | null;\n\n/**\n * A time-series chart with an overview brush. Drag or resize the brush window (or wheel and drag\n * on the main chart) and the main chart glides to the new domain. Drawing is imperative — paths,\n * ticks, brush and crosshair are written to the DOM from refs — so gestures never re-render React;\n * state only changes when a range settles.\n */\nexport function BrushChart({\n  data,\n  label,\n  type = \"area\",\n  curve = \"monotone\",\n  height = 260,\n  overviewHeight = 56,\n  defaultSpan = 0.35,\n  defaultRange,\n  duration = 450,\n  showGrid = true,\n  includeZero = false,\n  accent = \"#FF7448\",\n  formatValue,\n  formatTime,\n  onRangeChange,\n  className = \"\",\n  style,\n  ...props\n}: BrushChartProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId().replace(/:/g, \"\");\n  const summaryId = `${uid}-summary`;\n  const hintId = `${uid}-hint`;\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const mainRef = React.useRef<HTMLDivElement>(null);\n  const areaRef = React.useRef<SVGPathElement>(null);\n  const lineRef = React.useRef<SVGPathElement>(null);\n  const xTicksRef = React.useRef<SVGGElement>(null);\n  const yTicksRef = React.useRef<SVGGElement>(null);\n  const crossRef = React.useRef<SVGGElement>(null);\n  const tipRef = React.useRef<HTMLDivElement>(null);\n  const liveRef = React.useRef<HTMLSpanElement>(null);\n  const windowRef = React.useRef<SVGRectElement>(null);\n  const shadeLRef = React.useRef<SVGRectElement>(null);\n  const shadeRRef = React.useRef<SVGRectElement>(null);\n  const handleStartRef = React.useRef<SVGGElement>(null);\n  const handleEndRef = React.useRef<SVGGElement>(null);\n\n  const modelRef = React.useRef<Model | null>(null);\n  const currentRef = React.useRef<Domain | null>(null);\n  const targetRef = React.useRef<Domain | null>(null);\n  const frameRef = React.useRef(0);\n  const lastFrameRef = React.useRef(0);\n  const cursorRef = React.useRef<number | null>(null);\n  const brushDragRef = React.useRef<BrushDrag>(null);\n  const panRef = React.useRef<{ x: number; d0: number; d1: number; moved: boolean } | null>(null);\n  const committedKeyRef = React.useRef(\"\");\n  const commitTimer = React.useRef<number | undefined>(undefined);\n\n  const series = React.useMemo(() => toSeries(data), [data]);\n  const n = series.ts.length;\n  const full = React.useMemo<[number, number]>(() => {\n    const ts = series.ts;\n    if (!ts.length) return [0, 1];\n    return [ts[0], ts[ts.length - 1] > ts[0] ? ts[ts.length - 1] : ts[0] + 1];\n  }, [series]);\n  const minSpan = Math.min(full[1] - full[0], series.step * 4);\n\n  const clampRange = (d0: number, d1: number): [number, number] => {\n    const span = Math.min(full[1] - full[0], Math.max(minSpan, d1 - d0));\n    const start = Math.min(full[1] - span, Math.max(full[0], d0));\n    return [start, start + span];\n  };\n\n  const initialRange = (): [number, number] =>\n    defaultRange\n      ? clampRange(defaultRange[0], defaultRange[1])\n      : clampRange(full[1] - (full[1] - full[0]) * Math.min(1, Math.max(0.02, defaultSpan)), full[1]);\n\n  const [range, setRange] = React.useState<[number, number]>(initialRange);\n  const [width, setWidth] = React.useState(0);\n  const [prevSeries, setPrevSeries] = React.useState(series);\n  // New data: keep the window if it still fits, otherwise clamp it into the new domain.\n  if (series !== prevSeries) {\n    setPrevSeries(series);\n    setRange(clampRange(range[0], range[1]));\n  }\n\n  const fmtValue = formatValue ?? ((v: number) => fmtNumber.format(v));\n  const fmtTime = formatTime ?? ((t: number) => (series.step < DAY ? fmtFullHour.format(t) : fmtFullDay.format(t)));\n\n  const plotW = Math.max(0, width - MARGIN.left - MARGIN.right);\n  const plotH = Math.max(0, height - MARGIN.top - MARGIN.bottom);\n  const ovH = overviewHeight;\n\n  /* ---------------------------- imperative drawing ---------------------------- */\n\n  const draw = () => {\n    const m = modelRef.current;\n    const cur = currentRef.current;\n    const tgt = targetRef.current;\n    if (!m || !cur || !tgt || m.plotW <= 0) return;\n    const { ts, vs } = m.series;\n    const sx = (t: number) => MARGIN.left + ((t - cur.d0) / (cur.d1 - cur.d0)) * m.plotW;\n    const sy = (v: number) => MARGIN.top + (1 - (v - cur.y0) / (cur.y1 - cur.y0 || 1)) * m.plotH;\n\n    // Main series: the visible points plus one beyond each edge (clipped).\n    const from = Math.max(0, lowerBound(ts, cur.d0) - 1);\n    const to = Math.min(ts.length, lowerBound(ts, cur.d1) + 1);\n    const xs: number[] = [];\n    const ys: number[] = [];\n    for (let i = from; i < to; i++) {\n      xs.push(sx(ts[i]));\n      ys.push(sy(vs[i]));\n    }\n    const d = linePath(xs, ys, m.curve);\n    lineRef.current?.setAttribute(\"d\", d);\n    if (areaRef.current) {\n      const base = MARGIN.top + m.plotH;\n      areaRef.current.setAttribute(\"d\", xs.length ? `${d}L${f1(xs[xs.length - 1])},${base}L${f1(xs[0])},${base}Z` : \"\");\n    }\n\n    // Axis tick slots: show, move and relabel; hide the spare ones.\n    const xt = timeTicks(cur.d0, cur.d1, Math.max(2, Math.floor(m.plotW / 96)));\n    xTicksRef.current?.querySelectorAll<SVGGElement>(\":scope > g\").forEach((g, i) => {\n      const tick = xt[i];\n      g.style.display = tick ? \"\" : \"none\";\n      if (!tick) return;\n      g.setAttribute(\"transform\", `translate(${f1(sx(tick.t))},0)`);\n      const text = g.querySelector(\"text\");\n      if (text && text.textContent !== tick.label) text.textContent = tick.label;\n    });\n    const yt = valueTicks(cur.y0, cur.y1, Math.max(2, Math.floor(m.plotH / 52)));\n    yTicksRef.current?.querySelectorAll<SVGGElement>(\":scope > g\").forEach((g, i) => {\n      const v = yt[i];\n      g.style.display = v === undefined ? \"none\" : \"\";\n      if (v === undefined) return;\n      g.setAttribute(\"transform\", `translate(0,${f1(sy(v))})`);\n      const text = g.querySelector(\"text\");\n      const label = m.fmtValue(v);\n      if (text && text.textContent !== label) text.textContent = label;\n    });\n\n    // Brush follows the target directly (it is what the user is holding).\n    const ox = (t: number) => MARGIN.left + ((t - m.full[0]) / (m.full[1] - m.full[0])) * m.plotW;\n    const bx0 = ox(tgt.d0);\n    const bx1 = ox(tgt.d1);\n    windowRef.current?.setAttribute(\"x\", f1(bx0));\n    windowRef.current?.setAttribute(\"width\", f1(Math.max(1, bx1 - bx0)));\n    shadeLRef.current?.setAttribute(\"width\", f1(Math.max(0, bx0 - MARGIN.left)));\n    shadeRRef.current?.setAttribute(\"x\", f1(bx1));\n    shadeRRef.current?.setAttribute(\"width\", f1(Math.max(0, MARGIN.left + m.plotW - bx1)));\n    const sliders: [SVGGElement | null, number, number, number][] = [\n      [handleStartRef.current, tgt.d0, m.full[0], tgt.d1 - m.minSpan],\n      [handleEndRef.current, tgt.d1, tgt.d0 + m.minSpan, m.full[1]],\n    ];\n    for (const [el, t, min, max] of sliders) {\n      if (!el) continue;\n      el.setAttribute(\"transform\", `translate(${f1(ox(t))},0)`);\n      el.setAttribute(\"aria-valuenow\", String(Math.round(t)));\n      el.setAttribute(\"aria-valuemin\", String(Math.round(min)));\n      el.setAttribute(\"aria-valuemax\", String(Math.round(max)));\n      el.setAttribute(\"aria-valuetext\", m.fmtTime(t));\n    }\n\n    drawCursor();\n  };\n\n  const drawCursor = () => {\n    const m = modelRef.current;\n    const cur = currentRef.current;\n    const cross = crossRef.current;\n    const tip = tipRef.current;\n    if (!m || !cur || !cross || !tip) return;\n    const i = cursorRef.current;\n    const t = i === null ? NaN : m.series.ts[i];\n    if (i === null || t < cur.d0 || t > cur.d1) {\n      cross.style.display = \"none\";\n      tip.style.opacity = \"0\";\n      return;\n    }\n    const x = MARGIN.left + ((t - cur.d0) / (cur.d1 - cur.d0)) * m.plotW;\n    const y = MARGIN.top + (1 - (m.series.vs[i] - cur.y0) / (cur.y1 - cur.y0 || 1)) * m.plotH;\n    cross.style.display = \"\";\n    cross.setAttribute(\"transform\", `translate(${f1(x)},0)`);\n    cross.querySelector(\"circle\")?.setAttribute(\"cy\", f1(y));\n    tip.style.opacity = \"1\";\n    // Keep the tooltip inside the chart: flip to the left of the crosshair near the right edge.\n    const flip = x > MARGIN.left + m.plotW * 0.66;\n    tip.style.transform = `translate(${f1(flip ? x - 10 : x + 10)}px, ${f1(Math.max(MARGIN.top, y - 46))}px) translateX(${flip ? \"-100%\" : \"0\"})`;\n    const [timeEl, valueEl] = Array.from(tip.children);\n    if (timeEl) timeEl.textContent = m.fmtTime(t);\n    if (valueEl) valueEl.textContent = m.fmtValue(m.series.vs[i]);\n  };\n\n  /** Chases the target domain with an exponential ease; stops when it arrives. */\n  const tick = (now: number) => {\n    const m = modelRef.current;\n    const cur = currentRef.current;\n    const tgt = targetRef.current;\n    if (!m || !cur || !tgt) {\n      frameRef.current = 0;\n      return;\n    }\n    const dt = lastFrameRef.current ? Math.min(64, now - lastFrameRef.current) : 16;\n    lastFrameRef.current = now;\n    const k = m.tau <= 0 ? 1 : 1 - Math.exp(-dt / m.tau);\n    const keys = [\"d0\", \"d1\", \"y0\", \"y1\"] as const;\n    let done = true;\n    for (const key of keys) {\n      cur[key] += (tgt[key] - cur[key]) * k;\n      const scale = key[0] === \"d\" ? tgt.d1 - tgt.d0 : tgt.y1 - tgt.y0 || 1;\n      if (Math.abs(tgt[key] - cur[key]) > scale * 0.0005) done = false;\n    }\n    if (done) Object.assign(cur, tgt);\n    draw();\n    if (done) {\n      frameRef.current = 0;\n      lastFrameRef.current = 0;\n    } else {\n      frameRef.current = requestAnimationFrame(tick);\n    }\n  };\n\n  const kick = () => {\n    if (!frameRef.current) frameRef.current = requestAnimationFrame(tick);\n  };\n\n  const commit = () => {\n    window.clearTimeout(commitTimer.current);\n    const tgt = targetRef.current;\n    if (!tgt) return;\n    const next: [number, number] = [Math.round(tgt.d0), Math.round(tgt.d1)];\n    const key = next.join(\":\");\n    if (key === committedKeyRef.current) return;\n    committedKeyRef.current = key;\n    setRange(next);\n    onRangeChange?.(next);\n  };\n\n  const scheduleCommit = () => {\n    window.clearTimeout(commitTimer.current);\n    commitTimer.current = window.setTimeout(commit, 200);\n  };\n\n  /** Moves the target window (clamped) and starts the glide. */\n  const setTarget = (d0: number, d1: number) => {\n    const m = modelRef.current;\n    if (!m) return;\n    const span = Math.min(m.full[1] - m.full[0], Math.max(m.minSpan, d1 - d0));\n    const start = Math.min(m.full[1] - span, Math.max(m.full[0], d0));\n    const [y0, y1] = valueDomain(m.series, start, start + span, m.includeZero);\n    targetRef.current = { d0: start, d1: start + span, y0, y1 };\n    if (!currentRef.current) currentRef.current = { ...targetRef.current };\n    if (m.tau <= 0) Object.assign(currentRef.current, targetRef.current);\n    draw();\n    kick();\n  };\n\n  /* --------------------------------- effects --------------------------------- */\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver((entries) => setWidth(Math.round(entries[0].contentRect.width)));\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, []);\n\n  // Refresh the model after every render; sync the target when the committed range changed\n  // from outside a gesture (initial mount, new data).\n  React.useLayoutEffect(() => {\n    modelRef.current = {\n      series,\n      full,\n      minSpan,\n      width,\n      plotW,\n      plotH,\n      ovH,\n      type,\n      curve,\n      includeZero,\n      tau: reduceMotion ? 0 : duration / 4,\n      fmtValue,\n      fmtTime,\n    };\n    const key = `${Math.round(range[0])}:${Math.round(range[1])}`;\n    const tgt = targetRef.current;\n    if (!tgt || key !== committedKeyRef.current) {\n      committedKeyRef.current = key;\n      setTarget(range[0], range[1]);\n    } else {\n      // Same window, maybe new data or size: refresh the value domain and redraw.\n      setTarget(tgt.d0, tgt.d1);\n    }\n  });\n\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(frameRef.current);\n      window.clearTimeout(commitTimer.current);\n    },\n    [],\n  );\n\n  const onWheel = React.useEffectEvent((event: WheelEvent) => {\n    const m = modelRef.current;\n    const tgt = targetRef.current;\n    const el = mainRef.current;\n    if (!m || !tgt || !el) return;\n    event.preventDefault();\n    const rect = el.getBoundingClientRect();\n    const span = tgt.d1 - tgt.d0;\n    if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {\n      // Horizontal scroll pans.\n      const shift = ((event.shiftKey ? event.deltaY : event.deltaX) / m.plotW) * span;\n      setTarget(tgt.d0 + shift, tgt.d1 + shift);\n    } else {\n      const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left - MARGIN.left) / m.plotW));\n      const at = tgt.d0 + ratio * span;\n      const next = Math.min(m.full[1] - m.full[0], Math.max(m.minSpan, span * Math.exp(event.deltaY * 0.0018)));\n      setTarget(at - ratio * next, at - ratio * next + next);\n    }\n    scheduleCommit();\n  });\n\n  React.useEffect(() => {\n    const el = mainRef.current;\n    if (!el) return;\n    const handler = (event: WheelEvent) => onWheel(event);\n    el.addEventListener(\"wheel\", handler, { passive: false });\n    return () => el.removeEventListener(\"wheel\", handler);\n  }, []);\n\n  /* ------------------------------ main chart input ----------------------------- */\n\n  const timeAtMain = (clientX: number) => {\n    const m = modelRef.current;\n    const cur = currentRef.current;\n    const el = mainRef.current;\n    if (!m || !cur || !el) return null;\n    const x = clientX - el.getBoundingClientRect().left - MARGIN.left;\n    return cur.d0 + (x / m.plotW) * (cur.d1 - cur.d0);\n  };\n\n  const showCursorAt = (clientX: number) => {\n    const m = modelRef.current;\n    const t = timeAtMain(clientX);\n    if (!m || t === null || !m.series.ts.length) return;\n    cursorRef.current = nearestIndex(m.series.ts, t);\n    drawCursor();\n  };\n\n  const onMainPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    const tgt = targetRef.current;\n    if (!tgt || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    panRef.current = { x: event.clientX, d0: tgt.d0, d1: tgt.d1, moved: false };\n  };\n\n  const onMainPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const pan = panRef.current;\n    const m = modelRef.current;\n    if (pan && m) {\n      const dx = event.clientX - pan.x;\n      if (Math.abs(dx) > 3) pan.moved = true;\n      if (pan.moved) {\n        const shift = (-dx / m.plotW) * (pan.d1 - pan.d0);\n        setTarget(pan.d0 + shift, pan.d1 + shift);\n      }\n    }\n    if (event.pointerType === \"mouse\") showCursorAt(event.clientX);\n  };\n\n  const onMainPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    const pan = panRef.current;\n    panRef.current = null;\n    if (!pan) return;\n    if (pan.moved) commit();\n    // A tap (touch or pen) shows the crosshair at that point.\n    else showCursorAt(event.clientX);\n  };\n\n  const announce = (text: string) => {\n    if (liveRef.current) liveRef.current.textContent = text;\n  };\n\n  const onMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const m = modelRef.current;\n    const tgt = targetRef.current;\n    if (!m || !tgt || !m.series.ts.length) return;\n    const ts = m.series.ts;\n    const firstVisible = Math.min(ts.length - 1, lowerBound(ts, tgt.d0));\n    const lastVisible = Math.max(0, lowerBound(ts, tgt.d1 + 1) - 1);\n    let i = cursorRef.current ?? lastVisible;\n    const span = tgt.d1 - tgt.d0;\n    switch (event.key) {\n      case \"ArrowLeft\":\n        i = Math.max(0, i - (event.shiftKey ? 10 : 1));\n        break;\n      case \"ArrowRight\":\n        i = Math.min(ts.length - 1, i + (event.shiftKey ? 10 : 1));\n        break;\n      case \"Home\":\n        i = firstVisible;\n        break;\n      case \"End\":\n        i = lastVisible;\n        break;\n      case \"+\":\n      case \"=\":\n      case \"-\":\n      case \"_\": {\n        const at = ts[i];\n        const next = span * (event.key === \"+\" || event.key === \"=\" ? 0.7 : 1 / 0.7);\n        const ratio = (at - tgt.d0) / span;\n        setTarget(at - ratio * next, at - ratio * next + next);\n        commit();\n        event.preventDefault();\n        return;\n      }\n      case \"Escape\":\n        cursorRef.current = null;\n        drawCursor();\n        return;\n      default:\n        return;\n    }\n    event.preventDefault();\n    cursorRef.current = i;\n    // Walking off the visible window pans it along.\n    if (ts[i] < tgt.d0) setTarget(ts[i], ts[i] + span);\n    else if (ts[i] > tgt.d1) setTarget(ts[i] - span, ts[i]);\n    else drawCursor();\n    if (ts[i] < tgt.d0 || ts[i] > tgt.d1) commit();\n    announce(`${m.fmtTime(ts[i])}: ${m.fmtValue(m.series.vs[i])}`);\n  };\n\n  /* ------------------------------- brush input -------------------------------- */\n\n  const timeAtOverview = (clientX: number, el: Element) => {\n    const m = modelRef.current;\n    if (!m) return 0;\n    const x = clientX - el.getBoundingClientRect().left - MARGIN.left;\n    return m.full[0] + (x / m.plotW) * (m.full[1] - m.full[0]);\n  };\n\n  const onBrushPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {\n    const tgt = targetRef.current;\n    if (!tgt || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    const part = (event.target as Element).closest<SVGElement>(\"[data-brush]\")?.dataset.brush;\n    const svg = event.currentTarget;\n    svg.setPointerCapture(event.pointerId);\n    if (part === \"start\" || part === \"end\" || part === \"window\") {\n      brushDragRef.current = { kind: part, startX: event.clientX, d0: tgt.d0, d1: tgt.d1 };\n      if (part !== \"window\") (event.target as Element).closest<SVGGElement>(\"g[role=slider]\")?.focus({ preventScroll: true });\n    } else {\n      // Outside the window: centre it on the pointer, then keep dragging it.\n      const t = timeAtOverview(event.clientX, svg);\n      const span = tgt.d1 - tgt.d0;\n      setTarget(t - span / 2, t + span / 2);\n      const next = targetRef.current ?? tgt;\n      brushDragRef.current = { kind: \"window\", startX: event.clientX, d0: next.d0, d1: next.d1 };\n    }\n    rootRef.current?.setAttribute(\"data-brushing\", \"true\");\n    event.preventDefault();\n  };\n\n  const onBrushPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {\n    const drag = brushDragRef.current;\n    const m = modelRef.current;\n    if (!drag || !m) return;\n    const dt = ((event.clientX - drag.startX) / m.plotW) * (m.full[1] - m.full[0]);\n    if (drag.kind === \"window\") setTarget(drag.d0 + dt, drag.d1 + dt);\n    else if (drag.kind === \"start\") setTarget(Math.min(drag.d0 + dt, drag.d1 - m.minSpan), drag.d1);\n    else setTarget(drag.d0, Math.max(drag.d1 + dt, drag.d0 + m.minSpan));\n  };\n\n  const onBrushPointerUp = () => {\n    if (!brushDragRef.current) return;\n    brushDragRef.current = null;\n    rootRef.current?.removeAttribute(\"data-brushing\");\n    commit();\n  };\n\n  const onHandleKeyDown = (event: React.KeyboardEvent<SVGGElement>, edge: \"start\" | \"end\") => {\n    const m = modelRef.current;\n    const tgt = targetRef.current;\n    if (!m || !tgt) return;\n    const stepMs = m.series.step;\n    const big = (m.full[1] - m.full[0]) / 10;\n    let delta = 0;\n    let to: number | null = null;\n    switch (event.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        delta = -(event.shiftKey ? big : stepMs);\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        delta = event.shiftKey ? big : stepMs;\n        break;\n      case \"PageDown\":\n        delta = -big;\n        break;\n      case \"PageUp\":\n        delta = big;\n        break;\n      case \"Home\":\n        to = edge === \"start\" ? m.full[0] : tgt.d0 + m.minSpan;\n        break;\n      case \"End\":\n        to = edge === \"start\" ? tgt.d1 - m.minSpan : m.full[1];\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    if (edge === \"start\") setTarget(Math.min(to ?? tgt.d0 + delta, tgt.d1 - m.minSpan), tgt.d1);\n    else setTarget(tgt.d0, Math.max(to ?? tgt.d1 + delta, tgt.d0 + m.minSpan));\n    commit();\n  };\n\n  /* ---------------------------------- render ---------------------------------- */\n\n  // Overview path over the full domain (static per data and size, so React renders it).\n  const overview = React.useMemo(() => {\n    if (plotW <= 0 || !n) return { line: \"\", area: \"\" };\n    let min = Infinity;\n    let max = -Infinity;\n    for (const v of series.vs) {\n      min = Math.min(min, v);\n      max = Math.max(max, v);\n    }\n    const pad = (max - min || 1) * 0.08;\n    const xs = series.ts.map((t) => MARGIN.left + ((t - full[0]) / (full[1] - full[0])) * plotW);\n    const ys = series.vs.map((v) => 4 + (1 - (v - min + pad) / (max - min + pad * 2)) * (ovH - 8));\n    const line = linePath(xs, ys, curve);\n    return { line, area: `${line}L${f1(xs[xs.length - 1])},${ovH}L${f1(xs[0])},${ovH}Z` };\n  }, [series, plotW, ovH, curve, n, full]);\n\n  // Screen-reader summary of the committed range.\n  const summary = (() => {\n    if (!n) return `${label}: no data.`;\n    const from = lowerBound(series.ts, range[0]);\n    const to = Math.max(from, lowerBound(series.ts, range[1] + 1) - 1);\n    let lo = from;\n    let hi = from;\n    let sum = 0;\n    for (let i = from; i <= to; i++) {\n      if (series.vs[i] < series.vs[lo]) lo = i;\n      if (series.vs[i] > series.vs[hi]) hi = i;\n      sum += series.vs[i];\n    }\n    const count = to - from + 1;\n    return `${label}. Showing ${fmtTime(range[0])} to ${fmtTime(range[1])}: ${count} points, from ${fmtValue(series.vs[from])} to ${fmtValue(\n      series.vs[to],\n    )}, average ${fmtValue(sum / count)}, lowest ${fmtValue(series.vs[lo])} on ${fmtTime(series.ts[lo])}, highest ${fmtValue(series.vs[hi])} on ${fmtTime(\n      series.ts[hi],\n    )}.`;\n  })();\n\n  const gradientId = `${uid}-fill`;\n  const clipId = `${uid}-clip`;\n  const ready = width > 0;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      className={`group/bc w-full min-w-0 text-zinc-950 select-none dark:text-zinc-50 ${className}`}\n      style={{ [\"--bc-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {/* Main chart */}\n      <div\n        ref={mainRef}\n        tabIndex={0}\n        role=\"group\"\n        aria-roledescription=\"interactive chart\"\n        aria-label={`${label}, detail view`}\n        aria-describedby={`${summaryId} ${hintId}`}\n        onKeyDown={onMainKeyDown}\n        onPointerDown={onMainPointerDown}\n        onPointerMove={onMainPointerMove}\n        onPointerUp={onMainPointerUp}\n        onPointerCancel={() => {\n          panRef.current = null;\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType === \"mouse\" && !panRef.current) {\n            cursorRef.current = null;\n            drawCursor();\n          }\n        }}\n        onBlur={() => {\n          cursorRef.current = null;\n          drawCursor();\n        }}\n        className=\"relative cursor-crosshair touch-pan-y rounded-lg outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--bc-accent)] active:cursor-grabbing\"\n        style={{ height }}\n      >\n        {ready && (\n          <svg width={width} height={height} className=\"absolute inset-0 overflow-visible\" aria-hidden=\"true\">\n            <defs>\n              <linearGradient id={gradientId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"0\" stopColor={accent} stopOpacity=\"0.24\" />\n                <stop offset=\"1\" stopColor={accent} stopOpacity=\"0\" />\n              </linearGradient>\n              <clipPath id={clipId}>\n                <rect x={MARGIN.left} y={0} width={plotW} height={height} />\n              </clipPath>\n            </defs>\n            <g ref={yTicksRef}>\n              {Array.from({ length: Y_SLOTS }, (_, i) => (\n                <g key={i} style={{ display: \"none\" }}>\n                  {showGrid && <line x1={MARGIN.left} x2={MARGIN.left + plotW} className=\"stroke-zinc-900/[0.07] dark:stroke-white/[0.07]\" />}\n                  <text x={MARGIN.left - 10} dy=\"0.32em\" textAnchor=\"end\" className=\"fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400\" />\n                </g>\n              ))}\n            </g>\n            <line\n              x1={MARGIN.left}\n              x2={MARGIN.left + plotW}\n              y1={MARGIN.top + plotH}\n              y2={MARGIN.top + plotH}\n              className=\"stroke-zinc-900/20 dark:stroke-white/20\"\n            />\n            <g ref={xTicksRef} clipPath={`url(#${clipId})`}>\n              {Array.from({ length: X_SLOTS }, (_, i) => (\n                <g key={i} style={{ display: \"none\" }}>\n                  <line y1={MARGIN.top + plotH} y2={MARGIN.top + plotH + 4} className=\"stroke-zinc-900/25 dark:stroke-white/25\" />\n                  <text y={MARGIN.top + plotH + 17} textAnchor=\"middle\" className=\"fill-zinc-500 font-mono text-[10px] dark:fill-zinc-400\" />\n                </g>\n              ))}\n            </g>\n            <g clipPath={`url(#${clipId})`}>\n              {type === \"area\" && <path ref={areaRef} fill={`url(#${gradientId})`} />}\n              <path ref={lineRef} fill=\"none\" stroke={accent} strokeWidth={1.75} strokeLinejoin=\"round\" strokeLinecap=\"round\" />\n            </g>\n            <g ref={crossRef} style={{ display: \"none\" }} className=\"pointer-events-none\">\n              <line y1={MARGIN.top} y2={MARGIN.top + plotH} strokeDasharray=\"3 3\" className=\"stroke-zinc-900/35 dark:stroke-white/35\" />\n              <circle r={4.5} fill={accent} className=\"stroke-white dark:stroke-zinc-950\" strokeWidth={2} />\n            </g>\n          </svg>\n        )}\n        <div\n          ref={tipRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 flex flex-col gap-0.5 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-1.5 whitespace-nowrap opacity-0 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.35)] transition-opacity duration-150 dark:border-white/10 dark:bg-zinc-900/95\"\n        >\n          <span className=\"font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\" />\n          <span className=\"text-sm font-semibold tabular-nums tracking-[-0.01em]\" />\n        </div>\n      </div>\n\n      {/* Overview + brush */}\n      <div className=\"relative mt-3\" style={{ height: ovH }}>\n        {ready && (\n          <svg\n            width={width}\n            height={ovH}\n            className=\"absolute inset-0 touch-none overflow-visible\"\n            onPointerDown={onBrushPointerDown}\n            onPointerMove={onBrushPointerMove}\n            onPointerUp={onBrushPointerUp}\n            onPointerCancel={onBrushPointerUp}\n          >\n            <rect\n              x={MARGIN.left}\n              y={0}\n              width={plotW}\n              height={ovH}\n              rx={6}\n              className=\"cursor-pointer fill-zinc-900/[0.025] stroke-zinc-900/10 dark:fill-white/[0.03] dark:stroke-white/10\"\n            />\n            <path d={overview.area} className=\"pointer-events-none fill-zinc-900/[0.07] dark:fill-white/[0.08]\" />\n            <path d={overview.line} fill=\"none\" strokeWidth={1} className=\"pointer-events-none stroke-zinc-900/35 dark:stroke-white/35\" />\n            <rect ref={shadeLRef} x={MARGIN.left} y={0} height={ovH} className=\"pointer-events-none fill-zinc-100/60 dark:fill-zinc-950/55\" />\n            <rect ref={shadeRRef} y={0} height={ovH} className=\"pointer-events-none fill-zinc-100/60 dark:fill-zinc-950/55\" />\n            <rect\n              ref={windowRef}\n              data-brush=\"window\"\n              y={0.5}\n              height={ovH - 1}\n              fill={accent}\n              fillOpacity={0.1}\n              stroke={accent}\n              strokeWidth={1}\n              className=\"cursor-grab group-data-[brushing=true]/bc:cursor-grabbing\"\n            />\n            {([\"start\", \"end\"] as const).map((edge) => (\n              <g\n                key={edge}\n                ref={edge === \"start\" ? handleStartRef : handleEndRef}\n                data-brush={edge}\n                role=\"slider\"\n                tabIndex={0}\n                aria-label={edge === \"start\" ? \"Range start\" : \"Range end\"}\n                aria-orientation=\"horizontal\"\n                aria-controls={summaryId}\n                onKeyDown={(e) => onHandleKeyDown(e, edge)}\n                className=\"group/h cursor-ew-resize outline-hidden\"\n              >\n                <rect x={-10} y={0} width={20} height={ovH} fill=\"transparent\" />\n                <rect\n                  x={-4}\n                  y={ovH / 2 - 13}\n                  width={8}\n                  height={26}\n                  rx={3}\n                  className=\"fill-white stroke-zinc-900/30 group-focus-visible/h:stroke-[color:var(--bc-accent)] dark:fill-zinc-800 dark:stroke-white/40\"\n                  strokeWidth={1}\n                />\n                <rect\n                  x={-8}\n                  y={ovH / 2 - 17}\n                  width={16}\n                  height={34}\n                  rx={6}\n                  fill=\"none\"\n                  strokeWidth={2}\n                  className=\"stroke-transparent group-focus-visible/h:stroke-[color:var(--bc-accent)]\"\n                />\n                <line x1={-1.25} x2={-1.25} y1={ovH / 2 - 5} y2={ovH / 2 + 5} className=\"stroke-zinc-900/40 dark:stroke-white/50\" />\n                <line x1={1.25} x2={1.25} y1={ovH / 2 - 5} y2={ovH / 2 + 5} className=\"stroke-zinc-900/40 dark:stroke-white/50\" />\n              </g>\n            ))}\n          </svg>\n        )}\n      </div>\n\n      <p id={summaryId} className=\"sr-only\">\n        {summary}\n      </p>\n      <p id={hintId} className=\"sr-only\">\n        Left and right arrow keys move a crosshair between points, Home and End jump to the window edges, plus and minus zoom, Escape hides the crosshair.\n      </p>\n      <span ref={liveRef} className=\"sr-only\" aria-live=\"polite\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/brush-chart"
}
