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