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

@@ -26,11 +26,21 @@ type Props = {
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean;
fib?: FibRetracement | null;
fibOpacity?: number;
fibSelected?: boolean;
priceAutoScale?: boolean;
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void;
onChartCrosshairMove?: (p: FibAnchor) => void;
onPointerEvent?: (p: {
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
logical: number;
price: number;
x: number;
y: number;
target: 'chart' | 'fib';
event: PointerEvent;
}) => { consume?: boolean; capturePointer?: boolean } | void;
};
type LinePoint = LineData | WhitespaceData;
@@ -94,21 +104,26 @@ export default function TradingChart({
bb20,
showIndicators,
fib,
fibOpacity = 1,
fibSelected = false,
priceAutoScale = true,
onReady,
onChartClick,
onChartCrosshairMove,
onPointerEvent,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
const fibRef = useRef<FibRetracement | null>(fib ?? null);
const fibOpacityRef = useRef<number>(fibOpacity);
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const capturedOverlayPointerRef = useRef<number | null>(null);
const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
@@ -145,10 +160,19 @@ export default function TradingChart({
onChartCrosshairMoveRef.current = onChartCrosshairMove;
}, [onChartCrosshairMove]);
useEffect(() => {
onPointerEventRef.current = onPointerEvent;
}, [onPointerEvent]);
useEffect(() => {
fibRef.current = fib ?? null;
}, [fib]);
useEffect(() => {
fibOpacityRef.current = fibOpacity;
fibPrimitiveRef.current?.setOpacity(fibOpacity);
}, [fibOpacity]);
useEffect(() => {
priceAutoScaleRef.current = priceAutoScale;
}, [priceAutoScale]);
@@ -193,6 +217,7 @@ export default function TradingChart({
candleSeries.attachPrimitive(fibPrimitive);
fibPrimitiveRef.current = fibPrimitive;
fibPrimitive.setFib(fib ?? null);
fibPrimitive.setOpacity(fibOpacityRef.current);
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
@@ -283,6 +308,97 @@ export default function TradingChart({
chart.subscribeCrosshairMove(onCrosshairMove);
const container = containerRef.current;
const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => {
const logical = chart.timeScale().coordinateToLogical(x);
if (logical == null) return null;
const price = candleSeries.coordinateToPrice(y);
if (price == null) return null;
return { logical: Number(logical), price: Number(price) };
};
const isOverFib = (x: number, y: number, currentFib: FibRetracement): boolean => {
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
if (x1 == null || x2 == null) return false;
const tol = 6;
const left = Math.min(x1, x2) - tol;
const right = Math.max(x1, x2) + tol;
const p0 = currentFib.a.price;
const delta = currentFib.b.price - p0;
const yRatioMin = 0;
const yRatioMax = 4.236;
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const y1 = candleSeries.priceToCoordinate(pMin);
const y2 = candleSeries.priceToCoordinate(pMax);
if (y1 == null || y2 == null) return false;
const top = Math.min(y1, y2) - tol;
const bottom = Math.max(y1, y2) + tol;
return x >= left && x <= right && y >= top && y <= bottom;
};
const onOverlayPointer = (e: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const anchor = toAnchorFromPoint(x, y);
if (!anchor) return;
const currentFib = fibRef.current;
const target: 'chart' | 'fib' = currentFib && isOverFib(x, y, currentFib) ? 'fib' : 'chart';
const type =
e.type === 'pointerdown'
? ('pointerdown' as const)
: e.type === 'pointermove'
? ('pointermove' as const)
: e.type === 'pointerup'
? ('pointerup' as const)
: ('pointercancel' as const);
const decision = onPointerEventRef.current?.({
type,
logical: anchor.logical,
price: anchor.price,
x,
y,
target,
event: e,
});
if (decision?.capturePointer) {
capturedOverlayPointerRef.current = e.pointerId;
try {
containerRef.current.setPointerCapture(e.pointerId);
} catch {
// ignore
}
}
if (decision?.consume) {
e.preventDefault();
e.stopImmediatePropagation();
}
if (type === 'pointerup' || type === 'pointercancel') {
if (capturedOverlayPointerRef.current === e.pointerId) {
capturedOverlayPointerRef.current = null;
try {
containerRef.current.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
}
}
};
container?.addEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.addEventListener('pointermove', onOverlayPointer, { capture: true });
container?.addEventListener('pointerup', onOverlayPointer, { capture: true });
container?.addEventListener('pointercancel', onOverlayPointer, { capture: true });
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return;
if (!containerRef.current) return;
@@ -413,6 +529,10 @@ export default function TradingChart({
return () => {
chart.unsubscribeClick(onClick);
chart.unsubscribeCrosshairMove(onCrosshairMove);
container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.removeEventListener('pointermove', onOverlayPointer, { capture: true });
container?.removeEventListener('pointerup', onOverlayPointer, { capture: true });
container?.removeEventListener('pointercancel', onOverlayPointer, { capture: true });
container?.removeEventListener('wheel', onWheel, { capture: true });
container?.removeEventListener('pointerdown', onPointerDown, { capture: true });
container?.removeEventListener('pointermove', onPointerMove, { capture: true });