diff --git a/.gitignore b/.gitignore index 46f42c3..ea41b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,14 @@ +# Secrets (never commit) +tokens/* +!tokens/*.example.json +!tokens/*.example.yml +!tokens/*.example.yaml + node_modules/ dist/ .env *.log -tokens/*.json -tokens/*.yml -tokens/*.yaml + +# Local scratch / build output +_tmp/ +apps/visualizer/dist/ diff --git a/apps/visualizer/src/features/chart/ChartIcons.tsx b/apps/visualizer/src/features/chart/ChartIcons.tsx index 74d051b..3604cff 100644 --- a/apps/visualizer/src/features/chart/ChartIcons.tsx +++ b/apps/visualizer/src/features/chart/ChartIcons.tsx @@ -156,6 +156,21 @@ export function IconEye(props: IconProps) { ); } +export function IconLayers(props: IconProps) { + return ( + + + + + + ); +} + export function IconTrash(props: IconProps) { return ( diff --git a/apps/visualizer/src/features/chart/ChartLayersPanel.tsx b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx new file mode 100644 index 0000000..e4569f4 --- /dev/null +++ b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx @@ -0,0 +1,215 @@ +import { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import type { OverlayLayer } from './ChartPanel.types'; +import { IconEye, IconLock, IconTrash } from './ChartIcons'; + +type Props = { + open: boolean; + layers: OverlayLayer[]; + onRequestClose: () => void; + + onToggleLayerVisible: (layerId: string) => void; + onToggleLayerLocked: (layerId: string) => void; + onSetLayerOpacity: (layerId: string, opacity: number) => void; + + fibPresent: boolean; + fibSelected: boolean; + fibVisible: boolean; + fibLocked: boolean; + fibOpacity: number; + onSelectFib: () => void; + onToggleFibVisible: () => void; + onToggleFibLocked: () => void; + onSetFibOpacity: (opacity: number) => void; + onDeleteFib: () => void; +}; + +function clamp01(v: number): number { + if (!Number.isFinite(v)) return 1; + return Math.max(0, Math.min(1, v)); +} + +function opacityToPct(opacity: number): number { + return Math.round(clamp01(opacity) * 100); +} + +function pctToOpacity(pct: number): number { + if (!Number.isFinite(pct)) return 1; + return clamp01(pct / 100); +} + +function IconButton({ + title, + active, + disabled, + onClick, + children, +}: { + title: string; + active?: boolean; + disabled?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function OpacitySlider({ + value, + disabled, + onChange, +}: { + value: number; + disabled?: boolean; + onChange: (next: number) => void; +}) { + const pct = opacityToPct(value); + return ( +
+ onChange(pctToOpacity(Number(e.target.value)))} + disabled={disabled} + /> +
{pct}%
+
+ ); +} + +export default function ChartLayersPanel({ + open, + layers, + onRequestClose, + onToggleLayerVisible, + onToggleLayerLocked, + onSetLayerOpacity, + fibPresent, + fibSelected, + fibVisible, + fibLocked, + fibOpacity, + onSelectFib, + onToggleFibVisible, + onToggleFibLocked, + onSetFibOpacity, + onDeleteFib, +}: Props) { + const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]); + + return ( + <> +
+
+
+
Layers
+ +
+ +
+
+
+ +
+
+ +
+
Name
+
Opacity
+
Actions
+
+ + {drawingsLayer ? ( +
+
+ onToggleLayerVisible(drawingsLayer.id)} + > + + +
+
+ onToggleLayerLocked(drawingsLayer.id)} + > + + +
+
+
+ {drawingsLayer.name} + {fibPresent ? ' (1)' : ' (0)'} +
+
+
+ onSetLayerOpacity(drawingsLayer.id, next)} /> +
+
+
+ ) : null} + + {drawingsLayer && fibPresent ? ( +
+
+ + + +
+
+ + + +
+
+
Fib Retracement
+
+
+ +
+
+ + + +
+
+ ) : null} +
+
+ + ); +} + diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 2477b74..69f4b7a 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -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(null); const [fib, setFib] = useState(null); const [fibDraft, setFibDraft] = useState(null); - const [fibMove, setFibMove] = useState<{ start: FibAnchor; origin: FibRetracement } | null>(null); + const [layers, setLayers] = useState([ + { 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(null); const [priceAutoScale, setPriceAutoScale] = useState(true); + const chartApiRef = useRef(null); const activeToolRef = useRef(activeTool); const fibStartRef = useRef(fibStart); - const fibMoveRef = useRef<{ start: FibAnchor; origin: FibRetracement } | null>(fibMove); const pendingMoveRef = useRef(null); + const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null); const rafRef = useRef(null); + const spaceDownRef = useRef(false); + const dragRef = useRef(null); + const selectPointerRef = useRef(null); + const selectedOverlayIdRef = useRef(selectedOverlayId); + const fibRef = useRef(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) { + 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 ?
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({ -
diff --git a/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts b/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts index d51bc19..18ff45b 100644 --- a/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts +++ b/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts @@ -55,6 +55,7 @@ type State = { series: ISeriesApi<'Candlestick', Time> | null; chart: SeriesAttachedParameter