import { useEffect, useMemo, useRef, useState } from 'react'; import type { Candle, ChartIndicators } from '../../lib/api'; import Card from '../../ui/Card'; import ChartLayersPanel from './ChartLayersPanel'; import ChartSideToolbar from './ChartSideToolbar'; import ChartToolbar from './ChartToolbar'; import TradingChart from './TradingChart'; import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive'; import { LineStyle, type IChartApi } from 'lightweight-charts'; import type { OverlayLayer } from './ChartPanel.types'; type Props = { candles: Candle[]; indicators: ChartIndicators; dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null; timeframe: string; bucketSeconds: number; seriesKey: string; onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; showBuild: boolean; onToggleBuild: () => void; seriesLabel: string; }; type FibDragMode = 'move' | 'edit-b'; type FibDrag = { pointerId: number; mode: FibDragMode; startClientX: number; startClientY: number; start: FibAnchor; origin: FibRetracement; moved: boolean; }; function isEditableTarget(t: EventTarget | null): boolean { if (!(t instanceof HTMLElement)) return false; const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; return t.isContentEditable; } export default function ChartPanel({ candles, indicators, dlobQuotes, timeframe, bucketSeconds, seriesKey, onTimeframeChange, showIndicators, onToggleIndicators, showBuild, onToggleBuild, seriesLabel, }: Props) { const [isFullscreen, setIsFullscreen] = useState(false); const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor'); const [fibStart, setFibStart] = useState(null); const [fib, setFib] = useState(null); const [fibDraft, setFibDraft] = useState(null); const [layers, setLayers] = useState([ { id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 }, { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 }, ]); const [layersOpen, setLayersOpen] = useState(false); const [fibVisible, setFibVisible] = useState(true); const [fibLocked, setFibLocked] = useState(false); const [fibOpacity, setFibOpacity] = useState(1); const [selectedOverlayId, setSelectedOverlayId] = useState(null); const [priceAutoScale, setPriceAutoScale] = useState(true); const chartApiRef = useRef(null); const activeToolRef = useRef(activeTool); const fibStartRef = useRef(fibStart); const pendingMoveRef = useRef(null); const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null); const rafRef = useRef(null); const spaceDownRef = useRef(false); const dragRef = useRef(null); const selectPointerRef = useRef(null); const selectedOverlayIdRef = useRef(selectedOverlayId); const fibRef = useRef(fib); useEffect(() => { if (!isFullscreen) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsFullscreen(false); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [isFullscreen]); useEffect(() => { document.body.classList.toggle('chartFullscreen', isFullscreen); return () => document.body.classList.remove('chartFullscreen'); }, [isFullscreen]); const cardClassName = useMemo(() => { return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' '); }, [isFullscreen]); useEffect(() => { activeToolRef.current = activeTool; if (activeTool !== 'fib-retracement') { setFibStart(null); setFibDraft(null); } }, [activeTool]); useEffect(() => { fibStartRef.current = fibStart; }, [fibStart]); useEffect(() => { selectedOverlayIdRef.current = selectedOverlayId; }, [selectedOverlayId]); useEffect(() => { fibRef.current = fib; }, [fib]); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (isEditableTarget(e.target)) return; if (e.code === 'Space') { spaceDownRef.current = true; e.preventDefault(); return; } if (e.key === 'Escape') { if (dragRef.current) { dragRef.current = null; pendingDragRef.current = null; selectPointerRef.current = null; setFibDraft(null); return; } if (activeToolRef.current === 'fib-retracement') { setFibStart(null); setFibDraft(null); setActiveTool('cursor'); return; } if (selectedOverlayIdRef.current) setSelectedOverlayId(null); return; } if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedOverlayIdRef.current === 'fib') { clearFib(); } } }; const onKeyUp = (e: KeyboardEvent) => { if (e.code === 'Space') { spaceDownRef.current = false; } }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); }; }, []); function zoomTime(factor: number) { const chart = chartApiRef.current; if (!chart) return; const ts = chart.timeScale(); const range = ts.getVisibleLogicalRange(); if (!range) return; const from = range.from as number; const to = range.to as number; const span = Math.max(5, (to - from) * factor); const center = (from + to) / 2; ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 }); } function clamp01(v: number): number { if (!Number.isFinite(v)) return 1; return Math.max(0, Math.min(1, v)); } const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]); const quotesVisible = Boolean(quotesLayer?.visible); const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1); const priceLines = useMemo(() => { if (!quotesVisible) return []; return [ { id: 'dlob-bid', title: 'DLOB Bid', price: dlobQuotes?.bid ?? null, color: `rgba(34,197,94,${quotesOpacity})`, lineStyle: LineStyle.Dotted, }, { id: 'dlob-mid', title: 'DLOB Mid', price: dlobQuotes?.mid ?? null, color: `rgba(230,233,239,${quotesOpacity})`, lineStyle: LineStyle.Dashed, }, { id: 'dlob-ask', title: 'DLOB Ask', price: dlobQuotes?.ask ?? null, color: `rgba(239,68,68,${quotesOpacity})`, lineStyle: LineStyle.Dotted, }, ]; }, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]); function updateLayer(layerId: string, patch: Partial) { setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l))); } function clearFib() { setFib(null); setFibStart(null); setFibDraft(null); dragRef.current = null; pendingDragRef.current = null; selectPointerRef.current = null; setSelectedOverlayId(null); } function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement { if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer }; const deltaLogical = pointer.logical - drag.start.logical; const deltaPrice = pointer.price - drag.start.price; return { a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice }, b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice }, }; } function scheduleFrame() { if (rafRef.current != null) return; rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null; const drag = dragRef.current; const pendingDrag = pendingDragRef.current; if (drag && pendingDrag) { if (!drag.moved) { const dx = pendingDrag.clientX - drag.startClientX; const dy = pendingDrag.clientY - drag.startClientY; if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold } if (drag.moved) { setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor)); } return; } const pointer = pendingMoveRef.current; if (!pointer) return; if (activeToolRef.current !== 'fib-retracement') return; const start2 = fibStartRef.current; if (!start2) return; setFibDraft({ a: start2, b: pointer }); }); } const drawingsLayer = layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 }; const fibEffectiveVisible = fibVisible && drawingsLayer.visible; const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity; const fibEffectiveLocked = fibLocked || drawingsLayer.locked; const fibSelected = selectedOverlayId === 'fib'; const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null; useEffect(() => { if (selectedOverlayId !== 'fib') return; if (!fib) { setSelectedOverlayId(null); return; } if (!fibEffectiveVisible) setSelectedOverlayId(null); }, [fib, fibEffectiveVisible, selectedOverlayId]); return ( <> {isFullscreen ?
setIsFullscreen(false)} /> : null}
setPriceAutoScale((v) => !v)} seriesLabel={seriesLabel} isFullscreen={isFullscreen} onToggleFullscreen={() => setIsFullscreen((v) => !v)} />
setLayersOpen((v) => !v)} onZoomIn={() => zoomTime(0.8)} onZoomOut={() => zoomTime(1.25)} onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()} onClearFib={clearFib} />
{ chartApiRef.current = chart; }} onChartClick={(p) => { if (activeTool === 'fib-retracement') { if (!fibStartRef.current) { fibStartRef.current = p; setFibStart(p); setFibDraft({ a: p, b: p }); return; } setFib({ a: fibStartRef.current, b: p }); setFibStart(null); fibStartRef.current = null; setFibDraft(null); setActiveTool('cursor'); return; } if (p.target === 'chart') setSelectedOverlayId(null); }} onChartCrosshairMove={(p) => { pendingMoveRef.current = p; scheduleFrame(); }} onPointerEvent={({ type, logical, price, target, event }) => { const pointer: FibAnchor = { logical, price }; if (type === 'pointerdown') { if (event.button !== 0) return; if (spaceDownRef.current) return; if (activeToolRef.current !== 'cursor') return; if (target !== 'fib') return; if (!fibRef.current) return; if (!fibEffectiveVisible) return; if (selectedOverlayIdRef.current !== 'fib') { setSelectedOverlayId('fib'); selectPointerRef.current = event.pointerId; return { consume: true, capturePointer: true }; } if (fibEffectiveLocked) { selectPointerRef.current = event.pointerId; return { consume: true, capturePointer: true }; } dragRef.current = { pointerId: event.pointerId, mode: event.ctrlKey ? 'edit-b' : 'move', startClientX: event.clientX, startClientY: event.clientY, start: pointer, origin: fibRef.current, moved: false, }; pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY }; setFibDraft(fibRef.current); return { consume: true, capturePointer: true }; } const drag = dragRef.current; if (drag && drag.pointerId === event.pointerId) { if (type === 'pointermove') { pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY }; scheduleFrame(); return { consume: true }; } if (type === 'pointerup' || type === 'pointercancel') { if (drag.moved) setFib(computeFibFromDrag(drag, pointer)); dragRef.current = null; pendingDragRef.current = null; setFibDraft(null); return { consume: true }; } return; } if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) { if (type === 'pointermove') return { consume: true }; if (type === 'pointerup' || type === 'pointercancel') { selectPointerRef.current = null; return { consume: true }; } } }} /> setLayersOpen(false)} onToggleLayerVisible={(layerId) => { const layer = layers.find((l) => l.id === layerId); if (!layer) return; updateLayer(layerId, { visible: !layer.visible }); }} onToggleLayerLocked={(layerId) => { const layer = layers.find((l) => l.id === layerId); if (!layer) return; updateLayer(layerId, { locked: !layer.locked }); }} onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })} fibPresent={fib != null} fibSelected={fibSelected} fibVisible={fibVisible} fibLocked={fibLocked} fibOpacity={fibOpacity} onSelectFib={() => setSelectedOverlayId('fib')} onToggleFibVisible={() => setFibVisible((v) => !v)} onToggleFibLocked={() => setFibLocked((v) => !v)} onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))} onDeleteFib={clearFib} />
); }