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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -156,6 +156,21 @@ export function IconEye(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLayers(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Layers'} {...props}>
|
||||
<path
|
||||
d="M3.0 6.2L9.0 3.2L15.0 6.2L9.0 9.2L3.0 6.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M3.0 9.2L9.0 12.2L15.0 9.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.85" />
|
||||
<path d="M3.0 12.2L9.0 15.2L15.0 12.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.65" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash(props: IconProps) {
|
||||
return (
|
||||
<Svg title={props.title ?? 'Delete'} {...props}>
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FibAnchor | null>(null);
|
||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||
const [fibMove, setFibMove] = useState<{ start: FibAnchor; origin: FibRetracement } | null>(null);
|
||||
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||
{ 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<string | null>(null);
|
||||
const [priceAutoScale, setPriceAutoScale] = useState(true);
|
||||
|
||||
const chartApiRef = useRef<IChartApi | null>(null);
|
||||
const activeToolRef = useRef(activeTool);
|
||||
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||
const fibMoveRef = useRef<{ start: FibAnchor; origin: FibRetracement } | null>(fibMove);
|
||||
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const spaceDownRef = useRef<boolean>(false);
|
||||
const dragRef = useRef<FibDrag | null>(null);
|
||||
const selectPointerRef = useRef<number | null>(null);
|
||||
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
|
||||
const fibRef = useRef<FibRetracement | null>(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<OverlayLayer>) {
|
||||
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 ? <div className="chartBackdrop" onClick={() => 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}
|
||||
/>
|
||||
<div className="chartCard__chart">
|
||||
<TradingChart
|
||||
@@ -154,8 +295,9 @@ export default function ChartPanel({
|
||||
ema20={indicators.ema20}
|
||||
bb20={indicators.bb20}
|
||||
showIndicators={showIndicators}
|
||||
fib={fibDraft ?? fib}
|
||||
fibSelected={fibMove != null}
|
||||
fib={fibRenderable}
|
||||
fibOpacity={fibEffectiveOpacity}
|
||||
fibSelected={fibSelected}
|
||||
priceAutoScale={priceAutoScale}
|
||||
onReady={({ chart }) => {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChartLayersPanel
|
||||
open={layersOpen}
|
||||
layers={layers}
|
||||
onRequestClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type OverlayLayer = {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
opacity: number; // 0..1
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled>
|
||||
<button
|
||||
type="button"
|
||||
className={['chartToolBtn', isLayersOpen ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||
title="Layers"
|
||||
aria-label="Layers"
|
||||
onClick={onToggleLayers}
|
||||
>
|
||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||
<IconEye />
|
||||
<IconLayers />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@ type State = {
|
||||
series: ISeriesApi<'Candlestick', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
selected: boolean;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
@@ -65,8 +66,10 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { fib, series, chart, selected } = this._getState();
|
||||
const { fib, series, chart, selected, opacity } = this._getState();
|
||||
if (!fib || !series || !chart) return;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
if (clampedOpacity <= 0) return;
|
||||
|
||||
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||
@@ -79,6 +82,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
const delta = p1 - p0;
|
||||
|
||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||
context.save();
|
||||
context.globalAlpha *= clampedOpacity;
|
||||
try {
|
||||
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
||||
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||
@@ -157,6 +163,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||
context.fill();
|
||||
if (selected) context.stroke();
|
||||
}
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -178,6 +187,7 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||
private _fib: FibRetracement | null = null;
|
||||
private _selected = false;
|
||||
private _opacity = 1;
|
||||
private readonly _paneView: FibPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
@@ -187,6 +197,7 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
series: this._series,
|
||||
chart: this._param?.chart ?? null,
|
||||
selected: this._selected,
|
||||
opacity: this._opacity,
|
||||
}));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
@@ -214,4 +225,9 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||
this._selected = next;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setOpacity(next: number) {
|
||||
this._opacity = Number.isFinite(next) ? next : 1;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -689,6 +689,216 @@ a:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chartLayersBackdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 60;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.chartLayersBackdrop--open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chartLayersPanel {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
width: 420px;
|
||||
max-width: calc(100% - 16px);
|
||||
background: rgba(17, 19, 28, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(14px);
|
||||
overflow: hidden;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.chartLayersPanel--open {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chartLayersPanel__head {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chartLayersPanel__title {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chartLayersPanel__close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: transparent;
|
||||
color: rgba(230, 233, 239, 0.85);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chartLayersPanel__close:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(230, 233, 239, 0.92);
|
||||
}
|
||||
|
||||
.chartLayersTable {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chartLayersRow {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 34px minmax(0, 1fr) 150px 40px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chartLayersRow--head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: rgba(17, 19, 28, 0.92);
|
||||
color: rgba(230, 233, 239, 0.60);
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chartLayersRow--layer {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.chartLayersRow--object {
|
||||
cursor: pointer;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.chartLayersRow--object:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.chartLayersRow--selected {
|
||||
background: rgba(168, 85, 247, 0.12);
|
||||
}
|
||||
|
||||
.chartLayersCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chartLayersCell--icon {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chartLayersCell--name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chartLayersCell--opacity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chartLayersCell--actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.layersBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(0, 0, 0, 0.10);
|
||||
color: rgba(230, 233, 239, 0.88);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layersBtn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.layersBtn--active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layersBtn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.layersName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layersName--layer {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.layersName--object {
|
||||
font-weight: 800;
|
||||
color: rgba(230, 233, 239, 0.92);
|
||||
}
|
||||
|
||||
.layersName__meta {
|
||||
opacity: 0.65;
|
||||
font-weight: 800;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.layersOpacity {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layersOpacity__range {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layersOpacity__pct {
|
||||
font-size: 12px;
|
||||
color: rgba(230, 233, 239, 0.70);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chartCard__chart {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user