feat(chart): layers panel + selection-first fib

- Adds Layers drawer (visible/lock/opacity) and effective opacity wiring.
- Implements selection-first overlay: click selects, drag moves selected, Ctrl-drag edits B, Space-drag always pans.
- Routes pointer events via capture and adds Fib opacity support.
- Updates .gitignore to keep secrets/local scratch out of git.
This commit is contained in:
u1
2026-01-06 23:26:51 +01:00
parent 6107c4e0ef
commit a12c86f1f8
9 changed files with 859 additions and 65 deletions

View File

@@ -1,11 +1,13 @@
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 type { IChartApi } from 'lightweight-charts';
import type { OverlayLayer } from './ChartPanel.types';
type Props = {
candles: Candle[];
@@ -17,6 +19,25 @@ type Props = {
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,
@@ -31,14 +52,27 @@ export default function ChartPanel({
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
const [fib, setFib] = useState<FibRetracement | null>(null);
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
const [fibMove, setFibMove] = useState<{ start: FibAnchor; origin: FibRetracement } | null>(null);
const [layers, setLayers] = useState<OverlayLayer[]>([
{ 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 fibMoveRef = useRef<{ start: FibAnchor; origin: FibRetracement } | null>(fibMove);
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;
@@ -60,7 +94,6 @@ export default function ChartPanel({
useEffect(() => {
activeToolRef.current = activeTool;
if (activeTool === 'fib-retracement') setFibMove(null);
if (activeTool !== 'fib-retracement') {
setFibStart(null);
setFibDraft(null);
@@ -72,26 +105,63 @@ export default function ChartPanel({
}, [fibStart]);
useEffect(() => {
fibMoveRef.current = fibMove;
}, [fibMove]);
selectedOverlayIdRef.current = selectedOverlayId;
}, [selectedOverlayId]);
useEffect(() => {
fibRef.current = fib;
}, [fib]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (isEditableTarget(e.target)) return;
if (fibMoveRef.current) {
setFibMove(null);
setFibDraft(null);
if (e.code === 'Space') {
spaceDownRef.current = true;
e.preventDefault();
return;
}
if (activeToolRef.current !== 'fib-retracement') return;
setFibStart(null);
setFibDraft(null);
setActiveTool('cursor');
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);
return () => window.removeEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -113,6 +183,80 @@ export default function ChartPanel({
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));
}
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}
@@ -135,16 +279,13 @@ export default function ChartPanel({
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={() => {
setFib(null);
setFibStart(null);
setFibDraft(null);
setFibMove(null);
}}
onClearFib={clearFib}
/>
<div className="chartCard__chart">
<TradingChart
@@ -154,8 +295,9 @@ export default function ChartPanel({
ema20={indicators.ema20}
bb20={indicators.bb20}
showIndicators={showIndicators}
fib={fibDraft ?? fib}
fibSelected={fibMove != null}
fib={fibRenderable}
fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected}
priceAutoScale={priceAutoScale}
onReady={({ chart }) => {
chartApiRef.current = chart;
@@ -176,50 +318,101 @@ export default function ChartPanel({
return;
}
const move = fibMoveRef.current;
if (move) {
const deltaLogical = p.logical - move.start.logical;
const deltaPrice = p.price - move.start.price;
setFib({
a: { logical: move.origin.a.logical + deltaLogical, price: move.origin.a.price + deltaPrice },
b: { logical: move.origin.b.logical + deltaLogical, price: move.origin.b.price + deltaPrice },
});
setFibMove(null);
setFibDraft(null);
return;
}
if (p.target === 'fib' && fib) {
setFibMove({ start: p, origin: fib });
setFibDraft(fib);
}
if (p.target === 'chart') setSelectedOverlayId(null);
}}
onChartCrosshairMove={(p) => {
pendingMoveRef.current = p;
if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
const pointer = pendingMoveRef.current;
if (!pointer) return;
scheduleFrame();
}}
onPointerEvent={({ type, logical, price, target, event }) => {
const pointer: FibAnchor = { logical, price };
const move = fibMoveRef.current;
if (move) {
const deltaLogical = pointer.logical - move.start.logical;
const deltaPrice = pointer.price - move.start.price;
setFibDraft({
a: { logical: move.origin.a.logical + deltaLogical, price: move.origin.a.price + deltaPrice },
b: { logical: move.origin.b.logical + deltaLogical, price: move.origin.b.price + deltaPrice },
});
return;
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 (activeToolRef.current !== 'fib-retracement') return;
const start2 = fibStartRef.current;
if (!start2) return;
setFibDraft({ a: start2, b: pointer });
});
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>