SPB Git

Repository Public

Candles.tsx 14.6 KB 154 lines tsx

1/**2 * Candlestick chart on a canvas, in the house style (no charting dependency): candles + volume, moving averages, crosshair with3 * an OHLC read-out, and horizontal markers for the paper account (entry, liquidation ESTIMATE, resting orders, stops).4 * Live prices redraw the canvas directly inside requestAnimationFrame: no React render per tick, no render on pointer moves.5 * Rising candles are hollow and falling candles are filled, so direction does not rest on colour alone.6 */7import { useQuery } from '@tanstack/react-query';8import { useCallback, useEffect, useMemo, useRef, useState } from 'react';9import { useT, type Key } from '../i18n';10import { Icon } from '../ui';11import { dapi } from './api';12import { Seg, useLocalState } from './bits';13import { applyTick, sma, toBars, type Bar } from './reducers';14import type { MarketStore } from './store';15import { INTERVAL_MS, INTERVALS, type Interval } from './types';1617export interface Overlay { price: number; label: string; kind: 'entry' | 'liq' | 'order' | 'stop' | 'tp' }18const AXIS_W = 62, AXIS_H = 20, PAD_T = 10;19const cssVar = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#888';2021export function Candles({ store, interval, onInterval, overlays, priceDp, locale, stale, height }: { store: MarketStore; interval: Interval; onInterval: (i: Interval) => void; overlays: Overlay[]; priceDp: number; locale: string; stale: boolean; height: number }) {22  const t = useT();23  const box = useRef<HTMLDivElement>(null), canvas = useRef<HTMLCanvasElement>(null), readout = useRef<HTMLDivElement>(null);24  const bars = useRef<Bar[]>([]), hover = useRef<number | null>(null), raf = useRef(0), lastTrade = useRef(0), geom = useRef<{ first: number; n: number; cw: number; plotW: number } | null>(null);25  const [zoom, setZoom] = useLocalState<number>('spw-fut-zoom', 7);26  const [ma, setMa] = useLocalState<{ a: boolean; b: boolean }>('spw-fut-ma', { a: true, b: false });27  const [summary, setSummary] = useState('');28  const q = useQuery({ queryKey: ['deriv', 'candles', store.id, interval], queryFn: () => dapi.candles(store.id, interval, 300), refetchInterval: 60_000, staleTime: 0 });29  const nf = useMemo(() => new Intl.NumberFormat(locale === 'en' ? 'en-CA' : 'fr-CA', { minimumFractionDigits: priceDp, maximumFractionDigits: priceDp }), [locale, priceDp]);30  const vf = useMemo(() => new Intl.NumberFormat(locale === 'en' ? 'en-CA' : 'fr-CA', { notation: 'compact', maximumFractionDigits: 2 }), [locale]);31  const tf = useCallback((ts: number, long = false) => new Date(ts).toLocaleString(locale === 'en' ? 'en-CA' : 'fr-CA', INTERVAL_MS[interval] >= 86_400_000 ? { month: 'short', day: 'numeric' } : long ? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false } : { hour: '2-digit', minute: '2-digit', hour12: false }), [locale, interval]);3233  const draw = useCallback(() => {34    raf.current = 0;35    const cv = canvas.current, el = box.current; if (!cv || !el) return;36    const W = el.clientWidth, H = height, dpr = Math.min(3, window.devicePixelRatio || 1);37    if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); cv.style.width = `${W}px`; cv.style.height = `${H}px`; }38    const g = cv.getContext('2d'); if (!g) return;39    g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, W, H);40    const C = { up: cssVar('--emerald'), down: cssVar('--danger'), line: cssVar('--line'), mute: cssVar('--mute'), ink: cssVar('--ink'), surface: cssVar('--surface'), blue: cssVar('--blue'), warn: cssVar('--warn'), onp: cssVar('--onprimary'), primary: cssVar('--primary') };41    const all = bars.current, plotW = W - AXIS_W, plotH = H - AXIS_H - PAD_T, volH = Math.round(plotH * 0.2), priceH = plotH - volH - 8;42    g.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; g.textBaseline = 'middle';43    if (all.length < 2) { geom.current = null; g.fillStyle = C.mute; g.textAlign = 'center'; g.fillText(q.isError ? t('fxChartError') : t('fxLoading'), W / 2, H / 2); return; }44    const cw = zoom, n = Math.max(10, Math.min(all.length, Math.floor((plotW - 6) / cw))), first = all.length - n, vis = all.slice(first);45    geom.current = { first, n, cw, plotW };46    const ma1 = ma.a ? sma(all, 20).slice(first) : null, ma2 = ma.b ? sma(all, 50).slice(first) : null;47    let lo = Infinity, hi = -Infinity, vmax = 0;48    for (const b of vis) { lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); vmax = Math.max(vmax, b.v); }49    const padY = (hi - lo || hi * 0.001) * 0.07; lo -= padY; hi += padY;50    const x = (i: number) => plotW - (n - i) * cw + cw / 2, y = (p: number) => PAD_T + (1 - (p - lo) / (hi - lo)) * priceH;51    // grid and price axis52    const rough = (hi - lo) / 5, mag = 10 ** Math.floor(Math.log10(rough)), stepV = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= rough) ?? rough;53    g.strokeStyle = C.line; g.lineWidth = 1; g.fillStyle = C.mute; g.textAlign = 'left';54    for (let v = Math.ceil(lo / stepV) * stepV; v < hi; v += stepV) { const yy = Math.round(y(v)) + 0.5; g.beginPath(); g.moveTo(0, yy); g.lineTo(plotW, yy); g.stroke(); g.fillText(nf.format(v), plotW + 6, yy); }55    // time axis56    g.textAlign = 'center';57    const every = Math.max(1, Math.round(84 / cw));58    for (let i = n - 1 - Math.floor(every / 2); i >= 0; i -= every) { const xx = x(i); if (xx > 26 && xx < plotW - 22) g.fillText(tf(vis[i]!.ts), xx, H - AXIS_H / 2); }59    // volume and candles60    const bw = Math.max(1, Math.min(cw - 2, Math.floor(cw * 0.72)));61    for (let i = 0; i < n; i++) {62      const b = vis[i]!, up = b.c >= b.o, xx = Math.round(x(i)), col = up ? C.up : C.down;63      if (vmax > 0 && b.v > 0) { const vh = Math.max(1, (b.v / vmax) * volH); g.globalAlpha = 0.28; g.fillStyle = col; g.fillRect(xx - bw / 2, PAD_T + plotH - vh, bw, vh); g.globalAlpha = 1; }64      g.strokeStyle = col; g.fillStyle = col; g.lineWidth = 1;65      g.beginPath(); g.moveTo(xx + 0.5, y(b.h)); g.lineTo(xx + 0.5, y(b.l)); g.stroke();66      const top = y(Math.max(b.o, b.c)), hgt = Math.max(1, Math.abs(y(b.o) - y(b.c)));67      if (up && bw >= 3 && hgt >= 2) { g.fillStyle = C.surface; g.fillRect(xx - bw / 2, top, bw, hgt); g.strokeRect(xx - bw / 2 + 0.5, top + 0.5, bw - 1, hgt - 1); }68      else g.fillRect(xx - bw / 2, top, bw, hgt);69    }70    // moving averages (solid and dashed: two strokes, not only two colours)71    const line = (vals: (number | null)[], col: string, dash: number[]) => { g.strokeStyle = col; g.lineWidth = 1.4; g.setLineDash(dash); g.beginPath(); let pen = false;72      vals.forEach((v, i) => { if (v === null) return; if (pen) g.lineTo(x(i), y(v)); else { g.moveTo(x(i), y(v)); pen = true; } }); g.stroke(); g.setLineDash([]); };73    if (ma1) line(ma1, C.blue, []);74    if (ma2) line(ma2, C.warn, [5, 3]);75    // markers: entry, liquidation estimate, orders, stops76    const chip = (text: string, xx: number, yy: number, bg: string, fg: string, align: 'left' | 'right') => { const w = g.measureText(text).width + 10, x0 = align === 'left' ? xx : xx - w; g.fillStyle = bg; g.beginPath(); g.roundRect(x0, yy - 9, w, 18, 5); g.fill(); g.fillStyle = fg; g.textAlign = 'left'; g.fillText(text, x0 + 5, yy + 0.5); return w; };77    const colOf = { entry: C.blue, liq: C.down, order: C.mute, stop: C.warn, tp: C.up } as const;78    let edgeTop = PAD_T + 10, edgeBottom = PAD_T + priceH - 10;79    for (const o of overlays) {80      if (!Number.isFinite(o.price) || o.price <= 0) continue;81      const col = colOf[o.kind], text = `${o.label} ${nf.format(o.price)}`;82      if (o.price > hi) { chip(`▲ ${text}`, 4, edgeTop, col, '#fff', 'left'); edgeTop += 20; continue; }83      if (o.price < lo) { chip(`▼ ${text}`, 4, edgeBottom, col, '#fff', 'left'); edgeBottom -= 20; continue; }84      const yy = Math.round(y(o.price)) + 0.5;85      g.strokeStyle = col; g.lineWidth = 1; g.setLineDash(o.kind === 'entry' ? [] : [4, 3]); g.beginPath(); g.moveTo(0, yy); g.lineTo(plotW, yy); g.stroke(); g.setLineDash([]);86      chip(text, 4, yy, col, '#fff', 'left');87    }88    // last price89    const last = vis[n - 1]!, ly = Math.round(y(last.c)) + 0.5;90    g.strokeStyle = C.ink; g.globalAlpha = 0.5; g.setLineDash([2, 3]); g.beginPath(); g.moveTo(0, ly); g.lineTo(plotW, ly); g.stroke(); g.setLineDash([]); g.globalAlpha = 1;91    chip(nf.format(last.c), plotW + 2, ly, C.primary, C.onp, 'left');92    // crosshair93    const h = hover.current, cur = h !== null && h >= 0 && h < n ? vis[h]! : null;94    if (cur && h !== null) { const xx = Math.round(x(h)) + 0.5; g.strokeStyle = C.mute; g.lineWidth = 1; g.beginPath(); g.moveTo(xx, PAD_T); g.lineTo(xx, PAD_T + plotH); g.stroke();95      const lbl = tf(cur.ts, true), w = g.measureText(lbl).width + 10; chip(lbl, Math.max(0, Math.min(plotW - w, xx - w / 2)), H - AXIS_H / 2, C.primary, C.onp, 'left'); }96    // read-out (DOM text, so it is selectable and read by assistive tech on demand)97    const b = cur ?? last, prev = vis[(cur && h !== null ? h : n - 1) - 1], ch = prev ? ((b.c - prev.c) / prev.c) * 100 : 0;98    if (readout.current) readout.current.textContent = `${cur ? tf(b.ts, true) + ' · ' : ''}${t('fxO')} ${nf.format(b.o)}  ${t('fxH')} ${nf.format(b.h)}  ${t('fxL')} ${nf.format(b.l)}  ${t('fxC')} ${nf.format(b.c)}  ${ch >= 0 ? '+' : '−'}${Math.abs(ch).toFixed(2).replace('.', locale === 'en' ? '.' : ',')} %${b.v > 0 ? `  ${t('fxVol')} ${vf.format(b.v)}` : ''}`;99  }, [height, zoom, ma, overlays, nf, vf, tf, t, q.isError, locale]);100  const redraw = useCallback(() => { if (!raf.current) raf.current = requestAnimationFrame(draw); }, [draw]);101102  // a new market or interval starts from an empty series (declared before the history effect: it must run first)103  useEffect(() => { bars.current = []; hover.current = null; redraw(); }, [store, interval]);   // eslint-disable-line react-hooks/exhaustive-deps104  // history105  useEffect(() => {106    if (!q.data) return;107    bars.current = toBars(q.data);108    const tk = store.ticker; if (tk) bars.current = applyTick(bars.current, INTERVAL_MS[interval], Number(tk.mark), tk.ts);109    const f = bars.current[0], l = bars.current[bars.current.length - 1];110    if (f && l) setSummary(t('fxChartAria', { n: bars.current.length, interval, from: tf(f.ts, true), to: tf(l.ts, true), close: nf.format(l.c) }));111    redraw();112  }, [q.data, interval, store]);   // eslint-disable-line react-hooks/exhaustive-deps113  // live: mark price and trades go straight to the canvas114  useEffect(() => {115    const ms = INTERVAL_MS[interval];116    const onTicker = () => { const tk = store.ticker; if (!tk || !bars.current.length) return; const next = applyTick(bars.current, ms, Number(tk.mark), tk.ts); if (next !== bars.current) { bars.current = next; redraw(); } };117    const onTrades = () => { let next = bars.current; if (!next.length) return; const since = lastTrade.current;118      for (const tr of store.trades) { if (tr.ts <= since) break; lastTrade.current = Math.max(lastTrade.current, tr.ts); if (since) next = applyTick(next, ms, Number(tr.price), tr.ts, Number(tr.size)); }119      if (next !== bars.current) { bars.current = next; redraw(); } };120    lastTrade.current = 0;121    const a = store.subscribe('ticker', onTicker), b = store.subscribe('trades', onTrades);122    return () => { a(); b(); };123  }, [store, interval, redraw]);124  useEffect(() => { redraw(); const el = box.current; if (!el) return; const ro = new ResizeObserver(redraw); ro.observe(el); return () => { ro.disconnect(); if (raf.current) cancelAnimationFrame(raf.current); raf.current = 0; }; }, [redraw]);125126  const at = (clientX: number) => { const el = box.current, gm = geom.current; if (!el || !gm) return; const px = clientX - el.getBoundingClientRect().left; const i = Math.round((px - (gm.plotW - gm.n * gm.cw) - gm.cw / 2) / gm.cw); hover.current = Math.max(0, Math.min(gm.n - 1, i)); redraw(); };127  const leave = () => { hover.current = null; redraw(); };128  const key = (e: React.KeyboardEvent) => { const gm = geom.current; if (!gm) return;129    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); const cur = hover.current ?? gm.n - 1; hover.current = Math.max(0, Math.min(gm.n - 1, cur + (e.key === 'ArrowLeft' ? -1 : 1))); redraw(); }130    else if (e.key === 'Escape') leave(); };131  const tog = 'inline-flex min-h-11 items-center rounded-xl px-2.5 text-[12.5px] font-semibold';132  return (133    <div>134      <div className="flex flex-wrap items-center gap-2">135        <Seg size="sm" label={t('fxInterval')} value={interval} onChange={onInterval} options={INTERVALS.map((i) => ({ id: i, label: i, aria: t(`fxInt_${i}` as Key) }))} className="w-full sm:w-[330px]" />136        <div className="ml-auto flex items-center gap-1">137          <button type="button" aria-pressed={ma.a} onClick={() => setMa({ ...ma, a: !ma.a })} title={t('fxMaA')} aria-label={t('fxMaA')} className={`${tog} ${ma.a ? 'bg-blue/10 text-blue' : 'text-mute hover:bg-elev'}`}><span className="mr-1.5 inline-block h-0.5 w-3.5 bg-current" aria-hidden="true" />{t('fxMaShortA')}</button>138          <button type="button" aria-pressed={ma.b} onClick={() => setMa({ ...ma, b: !ma.b })} title={t('fxMaB')} aria-label={t('fxMaB')} className={`${tog} ${ma.b ? 'bg-warn/12 text-warn' : 'text-mute hover:bg-elev'}`}><span className="mr-1.5 inline-block h-0 w-3.5 border-t-2 border-dashed border-current" aria-hidden="true" />{t('fxMaShortB')}</button>139          <button type="button" onClick={() => setZoom(Math.max(3, zoom - 2))} disabled={zoom <= 3} aria-label={t('fxZoomOut')} className="grid h-11 w-11 place-items-center rounded-xl text-mute hover:bg-elev disabled:opacity-40"><Icon n="minus" className="h-4 w-4" /></button>140          <button type="button" onClick={() => setZoom(Math.min(19, zoom + 2))} disabled={zoom >= 19} aria-label={t('fxZoomIn')} className="grid h-11 w-11 place-items-center rounded-xl text-mute hover:bg-elev disabled:opacity-40"><Icon n="plus" className="h-4 w-4" /></button>141        </div>142      </div>143      <div ref={readout} className="mt-2 min-h-[18px] overflow-hidden whitespace-pre text-[11.5px] text-mute" data-testid="ohlc" />144      <div ref={box} tabIndex={0} role="img" aria-label={summary || t('fxChart')} onKeyDown={key} onBlur={leave}145        className={`relative mt-1 touch-pan-y select-none rounded-xl outline-offset-2 ${stale ? 'opacity-50 grayscale' : ''}`} style={{ height }}146        onPointerDown={(e) => at(e.clientX)} onPointerMove={(e) => { if (e.pointerType === 'mouse' || e.buttons || e.pressure > 0) at(e.clientX); }}147        onPointerUp={(e) => { if (e.pointerType !== 'mouse') leave(); }} onPointerLeave={leave} onPointerCancel={leave}>148        <canvas ref={canvas} className="block" data-testid="candles" />149      </div>150      {overlays.length > 0 && <ul className="sr-only">{overlays.map((o, i) => <li key={i}>{o.label} {nf.format(o.price)}</li>)}</ul>}151    </div>152  );153}154
Files main
Menu

Theme