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}
/>
{
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 };
+ }
+ }
}}
/>
+
+ 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}
+ />
diff --git a/apps/visualizer/src/features/chart/ChartPanel.types.ts b/apps/visualizer/src/features/chart/ChartPanel.types.ts
new file mode 100644
index 0000000..26baec5
--- /dev/null
+++ b/apps/visualizer/src/features/chart/ChartPanel.types.ts
@@ -0,0 +1,8 @@
+export type OverlayLayer = {
+ id: string;
+ name: string;
+ visible: boolean;
+ locked: boolean;
+ opacity: number; // 0..1
+};
+
diff --git a/apps/visualizer/src/features/chart/ChartSideToolbar.tsx b/apps/visualizer/src/features/chart/ChartSideToolbar.tsx
index f41bcc7..19c9c1d 100644
--- a/apps/visualizer/src/features/chart/ChartSideToolbar.tsx
+++ b/apps/visualizer/src/features/chart/ChartSideToolbar.tsx
@@ -4,8 +4,8 @@ import {
IconBrush,
IconCrosshair,
IconCursor,
- IconEye,
IconFib,
+ IconLayers,
IconLock,
IconPlus,
IconRuler,
@@ -24,7 +24,9 @@ type Props = {
timeframe: string;
activeTool: ActiveTool;
hasFib: boolean;
+ isLayersOpen: boolean;
onToolChange: (tool: ActiveTool) => void;
+ onToggleLayers: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onResetView: () => void;
@@ -35,7 +37,9 @@ export default function ChartSideToolbar({
timeframe,
activeTool,
hasFib,
+ isLayersOpen,
onToolChange,
+ onToggleLayers,
onZoomIn,
onZoomOut,
onResetView,
@@ -195,9 +199,15 @@ export default function ChartSideToolbar({
-