{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "candlestick-chart",
  "type": "registry:ui",
  "title": "Candlestick Chart",
  "description": "An OHLC candlestick chart with volume, a moving average, crosshair tooltip, wheel / drag pan-zoom and candle-by-candle keys.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/candlestick-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ---------------------------------- types ---------------------------------- */\n\nexport interface Candle {\n  /** Period start: \"YYYY-MM-DD\", an ISO string or epoch milliseconds. */\n  t: string | number;\n  open: number;\n  high: number;\n  low: number;\n  close: number;\n  volume?: number;\n}\n\nexport interface CandlestickChartProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  data: Candle[];\n  /** Accessible name of the chart, e.g. \"Daily price, sample series\". */\n  label: string;\n  /** Total height in px (price pane + volume pane + axis). */\n  height?: number;\n  /** Volume bars under the price pane. */\n  showVolume?: boolean;\n  /** Simple moving average of closes over this many candles (0 = off). */\n  maPeriod?: number;\n  /** Up candles drawn hollow and down candles filled, so direction never relies on colour alone. */\n  hollowUp?: boolean;\n  /** Candles visible at first (anchored to the latest). */\n  initialCount?: number;\n  /** Fewest candles you can zoom in to. */\n  minCount?: number;\n  upColor?: string;\n  downColor?: string;\n  /** Moving-average line, crosshair price tag and focus ring. */\n  accent?: string;\n  /** Decimals for prices. */\n  precision?: number;\n  /** BCP 47 locale for dates and volumes. */\n  locale?: string;\n}\n\n/* --------------------------------- demo data -------------------------------- */\n\n/** mulberry32: a tiny deterministic PRNG, so demo data is identical on server and client. */\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/**\n * Deterministic sample data: a seeded random walk on weekdays. Not market data — use it for demos\n * and tests only.\n */\nexport function randomWalkCandles({\n  count = 120,\n  seed = 10,\n  start = \"2026-03-02\",\n  price = 40,\n  volatility = 0.022,\n}: { count?: number; seed?: number; start?: string; price?: number; volatility?: number } = {}): Candle[] {\n  const rand = mulberry32(seed);\n  const gauss = () => {\n    // Box–Muller, clamped away from log(0).\n    const u = Math.max(rand(), 1e-9);\n    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rand());\n  };\n  const out: Candle[] = [];\n  let day = Date.parse(`${start}T00:00:00Z`);\n  let close = price;\n  const r2 = (n: number) => Math.round(n * 100) / 100;\n  while (out.length < count) {\n    const weekday = new Date(day).getUTCDay();\n    if (weekday !== 0 && weekday !== 6) {\n      const open = close * (1 + gauss() * volatility * 0.35);\n      const next = open * (1 + gauss() * volatility + 0.0006);\n      const high = Math.max(open, next) * (1 + Math.abs(gauss()) * volatility * 0.45);\n      const low = Math.min(open, next) * (1 - Math.abs(gauss()) * volatility * 0.45);\n      const move = Math.abs(next - open) / open;\n      out.push({\n        t: new Date(day).toISOString().slice(0, 10),\n        open: r2(open),\n        high: r2(high),\n        low: r2(low),\n        close: r2(next),\n        volume: Math.round(180_000 + rand() * 220_000 + move * 9_000_000),\n      });\n      close = next;\n    }\n    day += 86_400_000;\n  }\n  return out;\n}\n\n/* --------------------------------- helpers --------------------------------- */\n\nconst MARGIN = { top: 34, right: 58, bottom: 24, left: 10 };\nconst PANE_GAP = 10;\n\nconst toTime = (t: string | number) => (typeof t === \"number\" ? t : Date.parse(/^\\d{4}-\\d{2}-\\d{2}$/.test(t) ? `${t}T00:00:00Z` : t));\nconst r2 = (n: number) => Math.round(n * 100) / 100;\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\nfunction movingAverage(closes: number[], period: number) {\n  const out: (number | null)[] = new Array(closes.length).fill(null);\n  if (period < 2) return out;\n  let sum = 0;\n  for (let i = 0; i < closes.length; i++) {\n    sum += closes[i];\n    if (i >= period) sum -= closes[i - period];\n    if (i >= period - 1) out[i] = sum / period;\n  }\n  return out;\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * An OHLC candlestick chart in SVG with volume, a moving average, a crosshair tooltip, wheel /\n * drag pan and zoom, and keyboard reading candle by candle. Up candles are hollow and down candles\n * filled, so direction is not carried by colour alone.\n */\nexport function CandlestickChart({\n  data,\n  label,\n  height = 360,\n  showVolume = true,\n  maPeriod = 20,\n  hollowUp = true,\n  initialCount = 60,\n  minCount = 12,\n  upColor = \"#3FB984\",\n  downColor = \"#F2555A\",\n  accent = \"#FF7448\",\n  precision = 2,\n  locale = \"en-GB\",\n  className = \"\",\n  style,\n  ...props\n}: CandlestickChartProps) {\n  const uid = React.useId();\n  const summaryId = `${uid}-summary`;\n  const hintId = `${uid}-hint`;\n  const n = data.length;\n  const minVisible = Math.min(n, Math.max(2, minCount));\n\n  const [width, setWidth] = React.useState(0);\n  const initialView = () => {\n    const count = clamp(initialCount, minVisible, Math.max(minVisible, n));\n    return { start: Math.max(0, n - count), count };\n  };\n  const [view, setView] = React.useState(initialView);\n  const [prevLength, setPrevLength] = React.useState(n);\n  if (prevLength !== n) {\n    setPrevLength(n);\n    setView(initialView());\n  }\n  const [focusIndex, setFocusIndex] = React.useState<number | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const crossRef = React.useRef<SVGGElement>(null);\n  const tipRef = React.useRef<HTMLDivElement>(null);\n  const drag = React.useRef<{ x0: number; start0: number; pointerId: number; moved: boolean } | null>(null);\n  const pendingView = React.useRef<{ start: number; count: number } | null>(null);\n  const frame = React.useRef(0);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver((entries) => setWidth(Math.round(entries[0].contentRect.width)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  React.useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  /* --------------------------------- formats -------------------------------- */\n\n  let dateFmt: Intl.DateTimeFormat;\n  let fullFmt: Intl.DateTimeFormat;\n  try {\n    dateFmt = new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", timeZone: \"UTC\" });\n    fullFmt = new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" });\n  } catch {\n    dateFmt = new Intl.DateTimeFormat(\"en\", { day: \"numeric\", month: \"short\", timeZone: \"UTC\" });\n    fullFmt = new Intl.DateTimeFormat(\"en\", { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" });\n  }\n  const volFmt = new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 });\n  const tidy = (s: string) => s.replace(/[  ]/g, \" \");\n  const fmtDate = (i: number) => tidy(dateFmt.format(new Date(toTime(data[i].t))));\n  const fmtPrice = (v: number) => v.toFixed(precision);\n  const describe = (i: number) => {\n    const c = data[i];\n    return `${fmtDate(i)}: open ${fmtPrice(c.open)}, high ${fmtPrice(c.high)}, low ${fmtPrice(c.low)}, close ${fmtPrice(c.close)}`;\n  };\n\n  /* ---------------------------------- scales --------------------------------- */\n\n  const plotW = Math.max(0, width - MARGIN.left - MARGIN.right);\n  const inner = height - MARGIN.top - MARGIN.bottom;\n  const volH = showVolume ? Math.round(inner * 0.22) : 0;\n  const priceH = inner - (showVolume ? volH + PANE_GAP : 0);\n  const band = view.count > 0 ? plotW / view.count : 0;\n  const xOf = (i: number) => MARGIN.left + (i - view.start + 0.5) * band;\n\n  const i0 = Math.max(0, Math.floor(view.start));\n  const i1 = Math.min(n, Math.ceil(view.start + view.count));\n  let lo = Infinity;\n  let hi = -Infinity;\n  let vMax = 0;\n  for (let i = i0; i < i1; i++) {\n    lo = Math.min(lo, data[i].low);\n    hi = Math.max(hi, data[i].high);\n    vMax = Math.max(vMax, data[i].volume ?? 0);\n  }\n  if (!Number.isFinite(lo)) {\n    lo = 0;\n    hi = 1;\n  }\n  const padY = (hi - lo) * 0.06 || 1;\n  const yMin = lo - padY;\n  const yMax = hi + padY;\n  const yOf = (v: number) => MARGIN.top + ((yMax - v) / (yMax - yMin)) * priceH;\n  const volTop = MARGIN.top + priceH + PANE_GAP;\n  const vOf = (v: number) => volTop + volH - (vMax ? (v / vMax) * volH : 0);\n\n  const closes = data.map((d) => d.close);\n  const ma = movingAverage(closes, maPeriod);\n\n  /* ------------------------------- view changes ------------------------------ */\n\n  const clampView = (start: number, count: number) => {\n    const c = clamp(count, minVisible, Math.max(minVisible, n));\n    return { start: clamp(start, 0, Math.max(0, n - c)), count: c };\n  };\n\n  /** Commit view changes at most once per frame while dragging or wheeling. */\n  const scheduleView = (next: { start: number; count: number }) => {\n    pendingView.current = next;\n    if (frame.current) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = 0;\n      if (pendingView.current) setView(pendingView.current);\n      pendingView.current = null;\n    });\n  };\n\n  const currentView = () => pendingView.current ?? view;\n\n  /* -------------------------------- crosshair -------------------------------- */\n\n  const model = { xOf, yOf, band, start: view.start, count: view.count, plotW, priceH, yMin, yMax, width };\n\n  /** Positions the crosshair and tooltip imperatively (no React state per pointer move). */\n  const drawCross = (index: number | null, pointerY: number | null, m: typeof model) => {\n    const g = crossRef.current;\n    const tip = tipRef.current;\n    if (!g || !tip) return;\n    if (index === null || index < 0 || index >= n) {\n      g.style.display = \"none\";\n      tip.style.opacity = \"0\";\n      return;\n    }\n    const c = data[index];\n    const x = r2(m.xOf(index));\n    const y = pointerY === null ? m.yOf(c.close) : clamp(pointerY, MARGIN.top, MARGIN.top + m.priceH);\n    const value = m.yMax - ((y - MARGIN.top) / m.priceH) * (m.yMax - m.yMin);\n    g.style.display = \"\";\n    g.querySelector<SVGLineElement>(\"[data-v]\")?.setAttribute(\"transform\", `translate(${x},0)`);\n    g.querySelector<SVGGElement>(\"[data-h]\")?.setAttribute(\"transform\", `translate(0,${r2(y)})`);\n    const tag = g.querySelector<SVGTextElement>(\"[data-tag]\");\n    if (tag) tag.textContent = fmtPrice(value);\n    const up = c.close >= c.open;\n    const change = ((c.close - c.open) / c.open) * 100;\n    const set = (key: string, text: string) => {\n      const el = tip.querySelector<HTMLElement>(`[data-${key}]`);\n      if (el) el.textContent = text;\n    };\n    set(\"d\", tidy(fullFmt.format(new Date(toTime(c.t)))));\n    set(\"o\", fmtPrice(c.open));\n    set(\"h\", fmtPrice(c.high));\n    set(\"l\", fmtPrice(c.low));\n    set(\"c\", fmtPrice(c.close));\n    set(\"chg\", `${up ? \"▲\" : \"▼\"} ${change >= 0 ? \"+\" : \"\"}${change.toFixed(2)}%`);\n    set(\"v\", c.volume === undefined ? \"—\" : tidy(volFmt.format(c.volume)));\n    tip.querySelector<HTMLElement>(\"[data-chg]\")?.style.setProperty(\"color\", up ? upColor : downColor);\n    const tipW = tip.offsetWidth;\n    const left = x + 14 + tipW > m.width - MARGIN.right ? x - 14 - tipW : x + 14;\n    tip.style.transform = `translate(${Math.round(Math.max(4, left))}px, ${MARGIN.top + 6}px)`;\n    tip.style.opacity = \"1\";\n  };\n\n  // Keyboard focus keeps the crosshair on its candle across re-renders (pan, zoom, resize).\n  React.useLayoutEffect(() => {\n    if (focusIndex !== null) drawCross(focusIndex, null, model);\n  });\n\n  const indexAt = (clientX: number) => {\n    const rect = plotRef.current?.getBoundingClientRect();\n    if (!rect || band <= 0) return null;\n    const i = Math.floor((clientX - rect.left - MARGIN.left) / band + view.start);\n    return i >= i0 && i < i1 ? i : null;\n  };\n\n  /* ---------------------------------- pointer --------------------------------- */\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0 || n === 0) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = { x0: event.clientX, start0: view.start, pointerId: event.pointerId, moved: false };\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (d && d.pointerId === event.pointerId) {\n      const dx = event.clientX - d.x0;\n      if (Math.abs(dx) > 3) d.moved = true;\n      if (d.moved && band > 0) {\n        drawCross(null, null, model);\n        scheduleView(clampView(d.start0 - dx / band, currentView().count));\n        return;\n      }\n    }\n    if (focusIndex !== null) setFocusIndex(null);\n    const rect = plotRef.current?.getBoundingClientRect();\n    drawCross(indexAt(event.clientX), rect ? event.clientY - rect.top : null, model);\n  };\n\n  const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (drag.current?.pointerId === event.pointerId) drag.current = null;\n  };\n\n  const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" && !drag.current && focusIndex === null) drawCross(null, null, model);\n  };\n\n  /* ----------------------------------- wheel ---------------------------------- */\n\n  const onWheel = React.useEffectEvent((event: WheelEvent) => {\n    const rect = plotRef.current?.getBoundingClientRect();\n    if (!rect || plotW <= 0 || n === 0) return;\n    event.preventDefault();\n    const v = currentView();\n    if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {\n      const delta = (event.shiftKey ? event.deltaY : event.deltaX) / (plotW / v.count);\n      scheduleView(clampView(v.start + delta, v.count));\n      return;\n    }\n    // Zoom around the candle under the pointer.\n    const ratio = clamp((event.clientX - rect.left - MARGIN.left) / plotW, 0, 1);\n    const at = v.start + ratio * v.count;\n    const count = clamp(v.count * Math.exp(event.deltaY * 0.0016), minVisible, n);\n    scheduleView(clampView(at - ratio * count, count));\n  });\n\n  React.useEffect(() => {\n    const el = plotRef.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  /* --------------------------------- keyboard --------------------------------- */\n\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n\n  const focusCandle = (index: number) => {\n    const i = clamp(index, 0, n - 1);\n    setFocusIndex(i);\n    // Pan just enough to keep the candle in view.\n    if (i < view.start) setView(clampView(i, view.count));\n    else if (i + 1 > view.start + view.count) setView(clampView(i + 1 - view.count, view.count));\n    announce(describe(i));\n  };\n\n  const zoom = (factor: number) => {\n    const centre = focusIndex !== null ? focusIndex + 0.5 : view.start + view.count / 2;\n    const count = clamp(view.count * factor, minVisible, n);\n    const next = clampView(centre - count / 2, count);\n    setView(next);\n    announce(`Showing ${Math.round(next.count)} candles.`);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (n === 0) return;\n    const current = focusIndex ?? Math.min(n - 1, Math.floor(view.start + view.count) - 1);\n    const page = Math.max(1, Math.round(view.count));\n    switch (event.key) {\n      case \"ArrowRight\":\n        focusCandle(focusIndex === null ? current : current + 1);\n        break;\n      case \"ArrowLeft\":\n        focusCandle(focusIndex === null ? current : current - 1);\n        break;\n      case \"PageUp\":\n        focusCandle(current - page);\n        break;\n      case \"PageDown\":\n        focusCandle(current + page);\n        break;\n      case \"Home\":\n        focusCandle(0);\n        break;\n      case \"End\":\n        focusCandle(n - 1);\n        break;\n      case \"+\":\n      case \"=\":\n        zoom(0.75);\n        break;\n      case \"-\":\n      case \"_\":\n        zoom(1 / 0.75);\n        break;\n      case \"Escape\":\n        if (focusIndex === null) return;\n        // Dismiss the crosshair layer only.\n        event.stopPropagation();\n        setFocusIndex(null);\n        drawCross(null, null, model);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n  };\n\n  /* ---------------------------------- render ---------------------------------- */\n\n  const priceStep = niceStep((yMax - yMin) / 5);\n  const priceTicks: number[] = [];\n  for (let v = Math.ceil(yMin / priceStep) * priceStep; v <= yMax; v += priceStep) priceTicks.push(r2(v));\n  const labelEvery = Math.max(1, Math.ceil(78 / Math.max(band, 1)));\n\n  const bodyW = Math.max(1, Math.min(band * 0.68, 18));\n  const candles: React.ReactNode[] = [];\n  const volumes: React.ReactNode[] = [];\n  const dateLabels: React.ReactNode[] = [];\n  let maPath = \"\";\n  for (let i = Math.max(0, i0 - 1); i < Math.min(n, i1 + 1); i++) {\n    const c = data[i];\n    const x = r2(xOf(i));\n    const up = c.close >= c.open;\n    const color = up ? upColor : downColor;\n    const top = r2(yOf(Math.max(c.open, c.close)));\n    const bottom = r2(yOf(Math.min(c.open, c.close)));\n    const hollow = up && hollowUp;\n    candles.push(\n      <g key={i}>\n        <line x1={x} x2={x} y1={r2(yOf(c.high))} y2={top} stroke={color} strokeWidth={1} />\n        <line x1={x} x2={x} y1={bottom} y2={r2(yOf(c.low))} stroke={color} strokeWidth={1} />\n        <rect\n          x={r2(x - bodyW / 2)}\n          y={top}\n          width={r2(bodyW)}\n          height={Math.max(1, r2(bottom - top))}\n          fill={hollow ? \"var(--cc-surface)\" : color}\n          stroke={color}\n          strokeWidth={hollow ? 1.25 : 0}\n          rx={bodyW > 6 ? 1 : 0}\n        />\n      </g>,\n    );\n    if (showVolume && c.volume !== undefined) {\n      const vy = r2(vOf(c.volume));\n      volumes.push(\n        <rect\n          key={i}\n          x={r2(x - bodyW / 2)}\n          y={vy}\n          width={r2(bodyW)}\n          height={Math.max(0, r2(volTop + volH - vy))}\n          fill={color}\n          fillOpacity={hollow ? 0.28 : 0.55}\n        />,\n      );\n    }\n    if (i >= i0 && i < i1 && i % labelEvery === 0) {\n      dateLabels.push(\n        <text key={i} x={x} y={height - 7} textAnchor=\"middle\" className=\"fill-zinc-500 font-mono text-[10px] dark:fill-zinc-400\">\n          {fmtDate(i)}\n        </text>,\n      );\n    }\n    const m = ma[i];\n    if (maPeriod > 1 && m !== null) maPath += `${maPath ? \"L\" : \"M\"}${x},${r2(yOf(m))}`;\n  }\n\n  const first = n ? data[0] : null;\n  const last = n ? data[n - 1] : null;\n  let allLow = Infinity;\n  let allHigh = -Infinity;\n  for (const c of data) {\n    allLow = Math.min(allLow, c.low);\n    allHigh = Math.max(allHigh, c.high);\n  }\n  const totalChange = first && last ? ((last.close - first.open) / first.open) * 100 : 0;\n  const lastUp = last ? last.close >= last.open : true;\n\n  const rootStyle = {\n    [\"--cc-accent\" as string]: accent,\n    ...style,\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative w-full min-w-0 select-none text-zinc-950 [--cc-surface:white] dark:text-zinc-50 dark:[--cc-surface:var(--color-zinc-900)] ${className}`}\n      style={rootStyle}\n      {...props}\n    >\n      {/* Legend (decorative; the summary and announcements carry the meaning). */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute left-2.5 top-1 z-10 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n        {last ? (\n          <span className=\"text-[12px] font-semibold normal-case tracking-[-0.01em] text-zinc-900 tabular-nums dark:text-zinc-50\">\n            {fmtPrice(last.close)}{\" \"}\n            <span style={{ color: lastUp ? upColor : downColor }}>\n              {lastUp ? \"▲\" : \"▼\"} {(((last.close - last.open) / last.open) * 100).toFixed(2)}%\n            </span>\n          </span>\n        ) : null}\n        <span className=\"flex items-center gap-1.5\">\n          <svg width=\"10\" height=\"12\" viewBox=\"0 0 10 12\">\n            <rect x=\"2\" y=\"2\" width=\"6\" height=\"8\" fill={hollowUp ? \"none\" : upColor} stroke={upColor} strokeWidth=\"1.25\" />\n          </svg>\n          Up\n        </span>\n        <span className=\"flex items-center gap-1.5\">\n          <svg width=\"10\" height=\"12\" viewBox=\"0 0 10 12\">\n            <rect x=\"2\" y=\"2\" width=\"6\" height=\"8\" fill={downColor} />\n          </svg>\n          Down\n        </span>\n        {maPeriod > 1 ? (\n          <span className=\"flex items-center gap-1.5\">\n            <span className=\"h-[2px] w-3 rounded-full bg-[var(--cc-accent)]\" />\n            MA {maPeriod}\n          </span>\n        ) : null}\n      </div>\n\n      <div\n        ref={plotRef}\n        role=\"group\"\n        aria-roledescription=\"candlestick chart\"\n        aria-label={label}\n        aria-describedby={`${summaryId} ${hintId}`}\n        tabIndex={0}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onPointerLeave={handlePointerLeave}\n        onKeyDown={handleKeyDown}\n        onBlur={() => {\n          if (focusIndex !== null) {\n            setFocusIndex(null);\n            drawCross(null, null, model);\n          }\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-[var(--cc-accent)] active:cursor-grabbing\"\n        style={{ height }}\n      >\n        {width > 0 && n > 0 ? (\n          <svg width={width} height={height} className=\"absolute inset-0 overflow-hidden\" aria-hidden=\"true\">\n            <defs>\n              <clipPath id={`${uid}-clip`}>\n                <rect x={MARGIN.left} y={0} width={plotW} height={height} />\n              </clipPath>\n            </defs>\n            {/* Price grid + axis */}\n            {priceTicks.map((v) => (\n              <g key={v} transform={`translate(0,${r2(yOf(v))})`}>\n                <line x1={MARGIN.left} x2={MARGIN.left + plotW} className=\"stroke-zinc-900/[0.07] dark:stroke-white/[0.07]\" />\n                <text x={MARGIN.left + plotW + 8} dy=\"0.32em\" className=\"fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400\">\n                  {fmtPrice(v)}\n                </text>\n              </g>\n            ))}\n            {showVolume ? (\n              <line x1={MARGIN.left} x2={MARGIN.left + plotW} y1={volTop + volH + 0.5} y2={volTop + volH + 0.5} className=\"stroke-zinc-900/15 dark:stroke-white/15\" />\n            ) : null}\n            <g clipPath={`url(#${uid}-clip)`}>\n              {volumes}\n              {candles}\n              {maPath ? <path d={maPath} fill=\"none\" stroke=\"var(--cc-accent)\" strokeWidth={1.5} strokeLinejoin=\"round\" /> : null}\n            </g>\n            {dateLabels}\n            {showVolume ? (\n              <text x={MARGIN.left + plotW + 8} y={volTop + 8} className=\"fill-zinc-400 font-mono text-[9px] uppercase tracking-[0.12em] dark:fill-zinc-500\">\n                Vol\n              </text>\n            ) : null}\n\n            {/* Crosshair (positioned imperatively) */}\n            <g ref={crossRef} style={{ display: \"none\" }} className=\"pointer-events-none\">\n              <line data-v=\"\" y1={MARGIN.top} y2={height - MARGIN.bottom} strokeDasharray=\"3 3\" className=\"stroke-zinc-900/40 dark:stroke-white/40\" />\n              <g data-h=\"\">\n                <line x1={MARGIN.left} x2={MARGIN.left + plotW} strokeDasharray=\"3 3\" className=\"stroke-zinc-900/40 dark:stroke-white/40\" />\n                <rect x={MARGIN.left + plotW + 2} y={-9} width={MARGIN.right - 4} height={18} rx={4} fill=\"var(--cc-accent)\" />\n                <text data-tag=\"\" x={MARGIN.left + plotW + MARGIN.right / 2} dy=\"0.34em\" textAnchor=\"middle\" className=\"fill-zinc-950 font-mono text-[10px] font-semibold tabular-nums\" />\n              </g>\n            </g>\n          </svg>\n        ) : null}\n\n        <div\n          ref={tipRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 z-10 grid min-w-[9.5rem] grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 font-mono text-[10.5px] tabular-nums opacity-0 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.35)] transition-opacity duration-100 motion-reduce:transition-none dark:border-white/10 dark:bg-zinc-900/95\"\n        >\n          <span data-d=\"\" className=\"col-span-2 pb-1 text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\" />\n          <span className=\"text-zinc-500 dark:text-zinc-400\">O</span>\n          <span data-o=\"\" className=\"text-right\" />\n          <span className=\"text-zinc-500 dark:text-zinc-400\">H</span>\n          <span data-h=\"\" className=\"text-right\" />\n          <span className=\"text-zinc-500 dark:text-zinc-400\">L</span>\n          <span data-l=\"\" className=\"text-right\" />\n          <span className=\"text-zinc-500 dark:text-zinc-400\">C</span>\n          <span data-c=\"\" className=\"text-right font-semibold\" />\n          <span data-chg=\"\" className=\"col-span-2 pt-0.5 text-right\" />\n          <span className=\"text-zinc-500 dark:text-zinc-400\">Vol</span>\n          <span data-v=\"\" className=\"text-right\" />\n        </div>\n      </div>\n\n      <p id={summaryId} className=\"sr-only\">\n        {first && last\n          ? `${n} candles from ${tidy(fullFmt.format(new Date(toTime(first.t))))} to ${tidy(fullFmt.format(new Date(toTime(last.t))))}. Opened at ${fmtPrice(first.open)} and last closed at ${fmtPrice(last.close)} (${totalChange >= 0 ? \"+\" : \"\"}${totalChange.toFixed(1)}%). Lowest ${fmtPrice(allLow)}, highest ${fmtPrice(allHigh)}. Up candles are hollow, down candles filled.${maPeriod > 1 ? ` Line: ${maPeriod}-period moving average.` : \"\"}`\n          : \"No data.\"}\n      </p>\n      <p id={hintId} className=\"sr-only\">\n        Left and Right arrows read candle by candle; Page Up and Page Down jump a screen; Home and End go to the first and last candle; plus and minus zoom.\n      </p>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/candlestick-chart"
}
