471 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|