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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user