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:
215
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
215
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
className={['layersBtn', active ? 'layersBtn--active' : null].filter(Boolean).join(' ')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OpacitySlider({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
disabled?: boolean;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
const pct = opacityToPct(value);
|
||||
return (
|
||||
<div className="layersOpacity">
|
||||
<input
|
||||
className="layersOpacity__range"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={pct}
|
||||
onChange={(e) => onChange(pctToOpacity(Number(e.target.value)))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="layersOpacity__pct">{pct}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={['chartLayersBackdrop', open ? 'chartLayersBackdrop--open' : null].filter(Boolean).join(' ')}
|
||||
onClick={open ? onRequestClose : undefined}
|
||||
/>
|
||||
<div className={['chartLayersPanel', open ? 'chartLayersPanel--open' : null].filter(Boolean).join(' ')}>
|
||||
<div className="chartLayersPanel__head">
|
||||
<div className="chartLayersPanel__title">Layers</div>
|
||||
<button type="button" className="chartLayersPanel__close" onClick={onRequestClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chartLayersTable">
|
||||
<div className="chartLayersRow chartLayersRow--head">
|
||||
<div className="chartLayersCell chartLayersCell--icon" title="Visible">
|
||||
<IconEye />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon" title="Lock">
|
||||
<IconLock />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">Name</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">Opacity</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||
</div>
|
||||
|
||||
{drawingsLayer ? (
|
||||
<div className="chartLayersRow chartLayersRow--layer">
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton
|
||||
title="Toggle visible"
|
||||
active={drawingsLayer.visible}
|
||||
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
|
||||
>
|
||||
<IconEye />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton
|
||||
title="Toggle lock"
|
||||
active={drawingsLayer.locked}
|
||||
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
|
||||
>
|
||||
<IconLock />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">
|
||||
<div className="layersName layersName--layer">
|
||||
{drawingsLayer.name}
|
||||
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">
|
||||
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{drawingsLayer && fibPresent ? (
|
||||
<div
|
||||
className={['chartLayersRow', 'chartLayersRow--object', fibSelected ? 'chartLayersRow--selected' : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={onSelectFib}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton title="Toggle visible" active={fibVisible} onClick={onToggleFibVisible}>
|
||||
<IconEye />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton title="Toggle lock" active={fibLocked} onClick={onToggleFibLocked}>
|
||||
<IconLock />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">
|
||||
<div className="layersName layersName--object">Fib Retracement</div>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">
|
||||
<OpacitySlider value={fibOpacity} onChange={onSetFibOpacity} />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions">
|
||||
<IconButton title="Delete fib" onClick={onDeleteFib}>
|
||||
<IconTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user