Files
trade-frontend/apps/visualizer/src/features/chart/ChartPanel.tsx

471 lines
16 KiB
TypeScript

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<FibAnchor | null>(null);
const [fib, setFib] = useState<FibRetracement | null>(null);
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
const [layers, setLayers] = useState<OverlayLayer[]>([
{ 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<string | null>(null);
const [priceAutoScale, setPriceAutoScale] = useState(true);
const chartApiRef = useRef<IChartApi | null>(null);
const activeToolRef = useRef(activeTool);
const fibStartRef = useRef<FibAnchor | null>(fibStart);
const pendingMoveRef = useRef<FibAnchor | null>(null);
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
const rafRef = useRef<number | null>(null);
const spaceDownRef = useRef<boolean>(false);
const dragRef = useRef<FibDrag | null>(null);
const selectPointerRef = useRef<number | null>(null);
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
const fibRef = useRef<FibRetracement | null>(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<OverlayLayer>) {
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 ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
<Card className={cardClassName}>
<div className="chartCard__toolbar">
<ChartToolbar
timeframe={timeframe}
onTimeframeChange={onTimeframeChange}
showIndicators={showIndicators}
onToggleIndicators={onToggleIndicators}
showBuild={showBuild}
onToggleBuild={onToggleBuild}
priceAutoScale={priceAutoScale}
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
seriesLabel={seriesLabel}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
/>
</div>
<div className="chartCard__content">
<ChartSideToolbar
timeframe={timeframe}
activeTool={activeTool}
hasFib={fib != null || fibDraft != null}
isLayersOpen={layersOpen}
onToolChange={setActiveTool}
onToggleLayers={() => setLayersOpen((v) => !v)}
onZoomIn={() => zoomTime(0.8)}
onZoomOut={() => zoomTime(1.25)}
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
onClearFib={clearFib}
/>
<div className="chartCard__chart">
<TradingChart
candles={candles}
oracle={indicators.oracle}
sma20={indicators.sma20}
ema20={indicators.ema20}
bb20={indicators.bb20}
showIndicators={showIndicators}
showBuild={showBuild}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
priceLines={priceLines}
fib={fibRenderable}
fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected}
priceAutoScale={priceAutoScale}
onReady={({ chart }) => {
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 };
}
}
}}
/>
<ChartLayersPanel
open={layersOpen}
layers={layers}
onRequestClose={() => 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}
/>
</div>
</div>
</Card>
</>
);
}