1339 lines
53 KiB
TypeScript
1339 lines
53 KiB
TypeScript
import type { CSSProperties } from 'react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||
import AppShell from './layout/AppShell';
|
||
import ChartPanel from './features/chart/ChartPanel';
|
||
import { useChartData } from './features/chart/useChartData';
|
||
import TickerBar from './features/tickerbar/TickerBar';
|
||
import Card from './ui/Card';
|
||
import Tabs from './ui/Tabs';
|
||
import MarketHeader from './features/market/MarketHeader';
|
||
import Button from './ui/Button';
|
||
import TopNav from './layout/TopNav';
|
||
import AuthStatus from './layout/AuthStatus';
|
||
import LoginScreen from './layout/LoginScreen';
|
||
import { useDlobStats } from './features/market/useDlobStats';
|
||
import { useDlobL2 } from './features/market/useDlobL2';
|
||
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
||
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
||
import DlobDashboard from './features/market/DlobDashboard';
|
||
import ContractCostsPanel from './features/contracts/ContractCostsPanel';
|
||
|
||
type PaneId = 'chart' | 'dlob' | 'costsActive' | 'costsNew';
|
||
|
||
// Order matters: missing panes are appended in this order; last is default "top".
|
||
const ALL_PANES: PaneId[] = ['chart', 'dlob', 'costsActive', 'costsNew'];
|
||
|
||
function makePaneRecord<T>(factory: (id: PaneId) => T): Record<PaneId, T> {
|
||
const out: any = {};
|
||
for (const id of ALL_PANES) out[id] = factory(id);
|
||
return out as Record<PaneId, T>;
|
||
}
|
||
|
||
function normalizePaneOrder(raw: unknown): PaneId[] {
|
||
const out: PaneId[] = [];
|
||
const arr = Array.isArray(raw) ? raw : [];
|
||
for (const v of arr) {
|
||
if (v === 'chart' || v === 'dlob' || v === 'costsActive' || v === 'costsNew') {
|
||
if (!out.includes(v)) out.push(v);
|
||
}
|
||
}
|
||
for (const id of ALL_PANES) {
|
||
if (!out.includes(id)) out.push(id);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function normalizeLayerOpacity(raw: unknown): Record<PaneId, number> {
|
||
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
|
||
const clamp = (v: unknown, fallback: number) => {
|
||
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(1, Math.max(0, n));
|
||
};
|
||
return makePaneRecord((id) => clamp((input as any)[id], 1));
|
||
}
|
||
|
||
function normalizeLayerToggle(raw: unknown, fallback: boolean): Record<PaneId, boolean> {
|
||
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
|
||
const toBool = (v: unknown) => (typeof v === 'boolean' ? v : fallback);
|
||
return makePaneRecord((id) => toBool((input as any)[id]));
|
||
}
|
||
|
||
function normalizeLayerFactor(raw: unknown, fallback: number, min: number, max: number): Record<PaneId, number> {
|
||
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
|
||
const clamp = (v: unknown) => {
|
||
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(max, Math.max(min, n));
|
||
};
|
||
return makePaneRecord((id) => clamp((input as any)[id]));
|
||
}
|
||
|
||
function reorderList<T>(items: T[], from: T, to: T): T[] {
|
||
if (from === to) return items.slice();
|
||
const next = items.filter((x) => x !== from);
|
||
const idx = next.indexOf(to);
|
||
if (idx < 0) return next.concat(from);
|
||
next.splice(idx, 0, from);
|
||
return next;
|
||
}
|
||
|
||
function clampNumber(v: number, min: number, max: number): number {
|
||
if (!Number.isFinite(v)) return min;
|
||
return Math.min(max, Math.max(min, v));
|
||
}
|
||
|
||
function stepByWheel(e: React.WheelEvent, step: number): number {
|
||
// Wheel up => increase, wheel down => decrease
|
||
if (e.deltaY === 0) return 0;
|
||
return e.deltaY < 0 ? step : -step;
|
||
}
|
||
|
||
function envNumber(name: string, fallback: number): number {
|
||
const v = (import.meta as any).env?.[name];
|
||
if (v == null) return fallback;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
}
|
||
|
||
function envString(name: string, fallback: string): string {
|
||
const v = (import.meta as any).env?.[name];
|
||
return v == null ? fallback : String(v);
|
||
}
|
||
|
||
function formatUsd(v: number | null | undefined): string {
|
||
if (v == null || !Number.isFinite(v)) return '—';
|
||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||
return `$${v.toPrecision(4)}`;
|
||
}
|
||
|
||
function formatQty(v: number | null | undefined, decimals: number): string {
|
||
if (v == null || !Number.isFinite(v)) return '—';
|
||
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||
}
|
||
|
||
function formatCompact(v: number | null | undefined): string {
|
||
if (v == null || !Number.isFinite(v)) return '—';
|
||
const abs = Math.abs(v);
|
||
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||
if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`;
|
||
if (abs >= 1) return v.toFixed(2);
|
||
return v.toPrecision(4);
|
||
}
|
||
|
||
function clamp01(scale: number): number {
|
||
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
|
||
}
|
||
|
||
function barCurve(scale01: number): number {
|
||
// Makes small rows visible without letting a single wall dominate.
|
||
return Math.sqrt(clamp01(scale01));
|
||
}
|
||
|
||
function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties {
|
||
return {
|
||
['--ob-total-scale' as any]: barCurve(totalScale),
|
||
['--ob-level-scale' as any]: barCurve(levelScale),
|
||
} as CSSProperties;
|
||
}
|
||
|
||
function liquidityStyle(bid: number, ask: number): CSSProperties {
|
||
const max = Math.max(1e-9, bid, ask);
|
||
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
|
||
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
|
||
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
|
||
}
|
||
|
||
type WhoamiResponse = {
|
||
ok?: boolean;
|
||
user?: string | null;
|
||
mode?: string;
|
||
};
|
||
|
||
export default function App() {
|
||
const [user, setUser] = useState<string | null>(null);
|
||
const [authLoading, setAuthLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setAuthLoading(true);
|
||
fetch('/whoami', { cache: 'no-store' })
|
||
.then(async (res) => {
|
||
const json = (await res.json().catch(() => null)) as WhoamiResponse | null;
|
||
const u = typeof json?.user === 'string' ? json.user.trim() : '';
|
||
return u || null;
|
||
})
|
||
.then((u) => {
|
||
if (cancelled) return;
|
||
setUser(u);
|
||
})
|
||
.catch(() => {
|
||
if (cancelled) return;
|
||
setUser(null);
|
||
})
|
||
.finally(() => {
|
||
if (cancelled) return;
|
||
setAuthLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const logout = async () => {
|
||
try {
|
||
await fetch('/auth/logout', { method: 'POST' });
|
||
} finally {
|
||
setUser(null);
|
||
}
|
||
};
|
||
|
||
if (authLoading) {
|
||
return (
|
||
<div className="loginScreen">
|
||
<div className="loginCard" role="status" aria-label="Ładowanie">
|
||
Ładowanie…
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!user) {
|
||
return <LoginScreen onLoggedIn={setUser} />;
|
||
}
|
||
|
||
return <TradeApp user={user} onLogout={() => void logout()} />;
|
||
}
|
||
|
||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||
|
||
const normalizeTf = (raw: string): string => {
|
||
const v = String(raw || '').trim();
|
||
if (!v) return '1m';
|
||
const lower = v.toLowerCase();
|
||
// keep backwards compatibility with older saved values (e.g. "1D")
|
||
if (lower === '1d') return '1d';
|
||
return lower;
|
||
};
|
||
|
||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP'));
|
||
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
||
const [tfRaw, setTfRaw] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
|
||
const tf = useMemo(() => normalizeTf(tfRaw), [tfRaw]);
|
||
const setTf = (next: string) => setTfRaw(normalizeTf(next));
|
||
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
||
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
|
||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||
'dlob' | 'costs' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||
>('trade.bottomTab', envString('VITE_BOTTOM_TAB', 'dlob') as any);
|
||
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
||
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
||
'trade.form.type',
|
||
'market'
|
||
);
|
||
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||
|
||
const [layoutMode, setLayoutMode] = useLocalStorageState<'grid' | 'stack'>('trade.layoutMode', 'grid');
|
||
const [stackOrderRaw, setStackOrder] = useLocalStorageState<PaneId[]>('trade.stackOrder', ALL_PANES);
|
||
const stackOrder = useMemo(() => normalizePaneOrder(stackOrderRaw), [stackOrderRaw]);
|
||
const activePane = stackOrder[stackOrder.length - 1] ?? 'chart';
|
||
const [stackOrderManual, setStackOrderManual] = useLocalStorageState<boolean>('trade.stackOrderManual', false);
|
||
const escRef = useRef<number>(0);
|
||
const [stackPanelLocked, setStackPanelLocked] = useLocalStorageState<boolean>('trade.stackPanelLocked', false);
|
||
const [stackPanelOpen, setStackPanelOpen] = useState(true);
|
||
const stackPanelHideTimerRef = useRef<number | null>(null);
|
||
const [stackDrawerOpacity, setStackDrawerOpacity] = useLocalStorageState<number>('trade.stackDrawerOpacity', 0.92);
|
||
const [stackBackdropOpacity, setStackBackdropOpacity] = useLocalStorageState<number>('trade.stackBackdropOpacity', 0.55);
|
||
const [layerOpacityRaw, setLayerOpacity] = useLocalStorageState<Record<PaneId, number>>('trade.layerOpacity', {
|
||
chart: 1,
|
||
dlob: 1,
|
||
costsActive: 1,
|
||
costsNew: 1,
|
||
});
|
||
const layerOpacity = useMemo(() => normalizeLayerOpacity(layerOpacityRaw), [layerOpacityRaw]);
|
||
const [layerVisibleRaw, setLayerVisible] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerVisible', {
|
||
chart: true,
|
||
dlob: true,
|
||
costsActive: false,
|
||
costsNew: true,
|
||
});
|
||
const layerVisible = useMemo(() => normalizeLayerToggle(layerVisibleRaw, true), [layerVisibleRaw]);
|
||
const [layerLockedRaw, setLayerLocked] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerLocked', {
|
||
chart: false,
|
||
dlob: false,
|
||
costsActive: false,
|
||
costsNew: false,
|
||
});
|
||
const layerLocked = useMemo(() => normalizeLayerToggle(layerLockedRaw, false), [layerLockedRaw]);
|
||
const [layerBrightnessRaw, setLayerBrightness] = useLocalStorageState<Record<PaneId, number>>('trade.layerBrightness', {
|
||
chart: 1,
|
||
dlob: 1,
|
||
costsActive: 1,
|
||
costsNew: 1,
|
||
});
|
||
const layerBrightness = useMemo(() => normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8), [layerBrightnessRaw]);
|
||
const [activeContractIdSeen, setActiveContractIdSeen] = useLocalStorageState<string>('trade.activeContractIdSeen', '');
|
||
const [activeContractId] = useLocalStorageState<string>('trade.contractId', '');
|
||
const hasActiveContract = Boolean(activeContractId.trim());
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizePaneOrder(stackOrderRaw);
|
||
if (normalized.join('|') !== (Array.isArray(stackOrderRaw) ? stackOrderRaw.join('|') : '')) {
|
||
setStackOrder(normalized);
|
||
}
|
||
}, [setStackOrder, stackOrderRaw]);
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizeLayerOpacity(layerOpacityRaw);
|
||
const raw = layerOpacityRaw as any;
|
||
if (!raw || normalized.chart !== raw.chart || normalized.dlob !== raw.dlob) setLayerOpacity(normalized);
|
||
}, [layerOpacityRaw, setLayerOpacity]);
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizeLayerToggle(layerVisibleRaw, true);
|
||
const raw = layerVisibleRaw as any;
|
||
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
|
||
if (needsFix) setLayerVisible(normalized);
|
||
}, [layerVisibleRaw, setLayerVisible]);
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizeLayerToggle(layerLockedRaw, false);
|
||
const raw = layerLockedRaw as any;
|
||
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
|
||
if (needsFix) setLayerLocked(normalized);
|
||
}, [layerLockedRaw, setLayerLocked]);
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8);
|
||
const raw = layerBrightnessRaw as any;
|
||
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
|
||
if (needsFix) setLayerBrightness(normalized);
|
||
}, [layerBrightnessRaw, setLayerBrightness]);
|
||
|
||
// When a contract is "pushed" (contractId becomes non-empty), ensure Active layer is visible and placed
|
||
// directly below Costs(New) (unless user reorders later). We do this once per contract id.
|
||
useEffect(() => {
|
||
const id = activeContractId.trim();
|
||
if (!id) return;
|
||
if (id === activeContractIdSeen) return;
|
||
|
||
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), costsActive: true }));
|
||
|
||
if (!stackOrderManual) {
|
||
setStackOrder((prev) => {
|
||
const normalized = normalizePaneOrder(prev);
|
||
const without = normalized.filter((p) => p !== 'costsActive');
|
||
const idxNew = without.indexOf('costsNew');
|
||
const insertAt = idxNew >= 0 ? idxNew : Math.max(0, without.length - 1);
|
||
without.splice(insertAt, 0, 'costsActive');
|
||
return without;
|
||
});
|
||
}
|
||
|
||
setActiveContractIdSeen(id);
|
||
}, [activeContractId, activeContractIdSeen, setActiveContractIdSeen, setLayerVisible, setStackOrder, stackOrderManual]);
|
||
|
||
useEffect(() => {
|
||
document.body.classList.toggle('stackMode', layoutMode === 'stack');
|
||
return () => document.body.classList.remove('stackMode');
|
||
}, [layoutMode]);
|
||
|
||
useEffect(() => {
|
||
const drawer = clampNumber(stackDrawerOpacity, 0.05, 1);
|
||
const backdrop = clampNumber(stackBackdropOpacity, 0, 0.95);
|
||
document.body.style.setProperty('--stack-drawer-opacity', String(drawer));
|
||
document.body.style.setProperty('--stack-backdrop-opacity', String(backdrop));
|
||
return () => {
|
||
document.body.style.removeProperty('--stack-drawer-opacity');
|
||
document.body.style.removeProperty('--stack-backdrop-opacity');
|
||
};
|
||
}, [stackBackdropOpacity, stackDrawerOpacity]);
|
||
|
||
useEffect(() => {
|
||
if (layoutMode !== 'stack') return;
|
||
// Default focus order in stack mode unless the user manually reordered (DnD).
|
||
if (!stackOrderManual) {
|
||
setStackOrder(() => normalizePaneOrder(ALL_PANES));
|
||
}
|
||
setStackPanelOpen(true);
|
||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
||
stackPanelHideTimerRef.current = null;
|
||
}, [layoutMode, setStackOrder, stackOrderManual]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
||
stackPanelHideTimerRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (layoutMode !== 'stack') return;
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Escape') return;
|
||
const now = Date.now();
|
||
if (now - escRef.current < 800) {
|
||
escRef.current = 0;
|
||
setLayoutMode('grid');
|
||
return;
|
||
}
|
||
escRef.current = now;
|
||
};
|
||
window.addEventListener('keydown', onKeyDown);
|
||
return () => window.removeEventListener('keydown', onKeyDown);
|
||
}, [layoutMode, setLayoutMode]);
|
||
|
||
const openStackPanel = () => {
|
||
if (layoutMode !== 'stack') return;
|
||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
||
stackPanelHideTimerRef.current = null;
|
||
setStackPanelOpen(true);
|
||
};
|
||
|
||
const scheduleHideStackPanel = () => {
|
||
if (layoutMode !== 'stack') return;
|
||
if (stackPanelLocked) return;
|
||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
||
stackPanelHideTimerRef.current = window.setTimeout(() => {
|
||
setStackPanelOpen(false);
|
||
stackPanelHideTimerRef.current = null;
|
||
}, 1000);
|
||
};
|
||
|
||
const enterStack = (pane: PaneId) => {
|
||
if (pane === 'dlob') setBottomTab('dlob');
|
||
setLayoutMode('stack');
|
||
setStackOrder((prev) => {
|
||
const normalized = normalizePaneOrder(prev);
|
||
return normalized.filter((p) => p !== pane).concat(pane);
|
||
});
|
||
openStackPanel();
|
||
};
|
||
|
||
const exitStack = () => setLayoutMode('grid');
|
||
|
||
const togglePaneFullscreen = (pane: PaneId) => {
|
||
if (layoutMode === 'stack' && activePane === pane) {
|
||
exitStack();
|
||
return;
|
||
}
|
||
enterStack(pane);
|
||
};
|
||
|
||
const stackTopFirst = useMemo(() => stackOrder.slice().reverse(), [stackOrder]);
|
||
const stackZ = useMemo(() => {
|
||
const z: Record<PaneId, number> = makePaneRecord(() => 2600);
|
||
const base = 2600;
|
||
const step = 20;
|
||
for (let i = 0; i < stackOrder.length; i++) {
|
||
const id = stackOrder[i];
|
||
z[id] = base + i * step;
|
||
}
|
||
return z;
|
||
}, [stackOrder]);
|
||
|
||
const effectiveLayerOpacity = useMemo(() => {
|
||
const out: Record<PaneId, number> = makePaneRecord(() => 0);
|
||
for (const id of ALL_PANES) {
|
||
out[id] = layerVisible[id] ? clampNumber(layerOpacity[id] ?? 1, 0, 1) : 0;
|
||
}
|
||
return out;
|
||
}, [layerOpacity, layerVisible]);
|
||
|
||
useEffect(() => {
|
||
if (symbol === 'BONK-PERP') {
|
||
setSymbol('1MBONK-PERP');
|
||
return;
|
||
}
|
||
if (!markets.includes(symbol)) {
|
||
setSymbol('SOL-PERP');
|
||
}
|
||
}, [markets, setSymbol, symbol]);
|
||
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const wanted = (params.get('bottomTab') || params.get('tab') || '').trim();
|
||
const allowed = new Set(['dlob', 'costs', 'positions', 'orders', 'balances', 'orderHistory', 'positionHistory']);
|
||
if (wanted && allowed.has(wanted)) setBottomTab(wanted as any);
|
||
}, [setBottomTab]);
|
||
|
||
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||
symbol,
|
||
source: source.trim() ? source : undefined,
|
||
tf,
|
||
limit,
|
||
pollMs,
|
||
});
|
||
|
||
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
|
||
const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 10 });
|
||
const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
|
||
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol);
|
||
|
||
const latest = candles.length ? candles[candles.length - 1] : null;
|
||
const first = candles.length ? candles[0] : null;
|
||
const changePct =
|
||
first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null;
|
||
|
||
const orderbook = useMemo(() => {
|
||
if (dlobL2) {
|
||
return {
|
||
asks: dlobL2.asks,
|
||
bids: dlobL2.bids,
|
||
mid: dlobL2.mid as number | null,
|
||
bestBid: dlobL2.bestBid,
|
||
bestAsk: dlobL2.bestAsk,
|
||
};
|
||
}
|
||
if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null };
|
||
const mid = latest.close;
|
||
const step = Math.max(mid * 0.00018, 0.0001);
|
||
const levels = 10;
|
||
|
||
const asksRaw = Array.from({ length: levels }, (_, i) => ({
|
||
price: mid + (i + 1) * step,
|
||
sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
|
||
}));
|
||
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
|
||
price: mid - (i + 1) * step,
|
||
sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
|
||
}));
|
||
|
||
let askTotalBase = 0;
|
||
let askTotalUsd = 0;
|
||
const asks = asksRaw
|
||
.slice()
|
||
.reverse()
|
||
.map((r) => {
|
||
const sizeUsd = r.sizeBase * r.price;
|
||
askTotalBase += r.sizeBase;
|
||
askTotalUsd += sizeUsd;
|
||
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
|
||
});
|
||
|
||
let bidTotalBase = 0;
|
||
let bidTotalUsd = 0;
|
||
const bids = bidsRaw.map((r) => {
|
||
const sizeUsd = r.sizeBase * r.price;
|
||
bidTotalBase += r.sizeBase;
|
||
bidTotalUsd += sizeUsd;
|
||
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
|
||
});
|
||
|
||
return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null };
|
||
}, [dlobL2, latest]);
|
||
|
||
const maxAskTotal = useMemo(() => {
|
||
let max = 0;
|
||
for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0);
|
||
return max;
|
||
}, [orderbook.asks]);
|
||
|
||
const maxBidTotal = useMemo(() => {
|
||
let max = 0;
|
||
for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0);
|
||
return max;
|
||
}, [orderbook.bids]);
|
||
|
||
const maxAskSize = useMemo(() => {
|
||
let max = 0;
|
||
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
|
||
return max;
|
||
}, [orderbook.asks]);
|
||
|
||
const maxBidSize = useMemo(() => {
|
||
let max = 0;
|
||
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
|
||
return max;
|
||
}, [orderbook.bids]);
|
||
|
||
const liquidity = useMemo(() => {
|
||
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
|
||
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
|
||
const bestBid = orderbook.bestBid;
|
||
const bestAsk = orderbook.bestAsk;
|
||
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
|
||
const spreadPct =
|
||
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
|
||
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
|
||
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
|
||
|
||
const trades = useMemo(() => {
|
||
const slice = candles.slice(-24).reverse();
|
||
return slice.map((c) => {
|
||
const isBuy = c.close >= c.open;
|
||
return {
|
||
time: c.time,
|
||
price: c.close,
|
||
size: c.volume ?? null,
|
||
side: isBuy ? ('buy' as const) : ('sell' as const),
|
||
};
|
||
});
|
||
}, [candles]);
|
||
|
||
const effectiveTradePrice = useMemo(() => {
|
||
if (tradeOrderType === 'limit') return tradePrice;
|
||
return latest?.close ?? tradePrice;
|
||
}, [latest?.close, tradeOrderType, tradePrice]);
|
||
|
||
const orderValueUsd = useMemo(() => {
|
||
if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null;
|
||
if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null;
|
||
const v = effectiveTradePrice * tradeSize;
|
||
return Number.isFinite(v) && v > 0 ? v : null;
|
||
}, [effectiveTradePrice, tradeSize]);
|
||
|
||
const dynamicSlippage = useMemo(() => {
|
||
if (orderValueUsd == null) return null;
|
||
const side = tradeSide === 'short' ? 'sell' : 'buy';
|
||
const rows = slippageRows.filter((r) => r.side === side).slice();
|
||
rows.sort((a, b) => a.sizeUsd - b.sizeUsd);
|
||
if (!rows.length) return null;
|
||
const biggest = rows[rows.length - 1];
|
||
const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest;
|
||
return match;
|
||
}, [orderValueUsd, slippageRows, tradeSide]);
|
||
|
||
const topItems = useMemo(
|
||
() => [
|
||
{ key: 'BTC', label: 'BTC', changePct: 1.28, active: false },
|
||
{ key: 'SOL', label: 'SOL', changePct: 1.89, active: false },
|
||
],
|
||
[]
|
||
);
|
||
|
||
const stats = useMemo(() => {
|
||
return [
|
||
{
|
||
key: 'last',
|
||
label: 'Last',
|
||
value: formatUsd(latest?.close),
|
||
sub:
|
||
changePct == null ? (
|
||
'—'
|
||
) : (
|
||
<span className={changePct >= 0 ? 'pos' : 'neg'}>
|
||
{changePct >= 0 ? '+' : ''}
|
||
{changePct.toFixed(2)}%
|
||
</span>
|
||
),
|
||
},
|
||
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
|
||
{ key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) },
|
||
{ key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) },
|
||
{
|
||
key: 'spread',
|
||
label: 'Spread',
|
||
value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`,
|
||
sub: formatUsd(dlob?.spreadAbs ?? null),
|
||
},
|
||
{
|
||
key: 'dlob',
|
||
label: 'DLOB',
|
||
value: dlobConnected ? 'live' : '—',
|
||
sub: dlobError ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
|
||
},
|
||
{
|
||
key: 'l2',
|
||
label: 'L2',
|
||
value: dlobL2Connected ? 'live' : '—',
|
||
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : dlobL2?.updatedAt || '—',
|
||
},
|
||
];
|
||
}, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]);
|
||
|
||
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
||
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
||
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
||
|
||
return (
|
||
<>
|
||
<AppShell
|
||
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
||
top={<TickerBar items={topItems} />}
|
||
main={
|
||
<div className="tradeMain">
|
||
<Card
|
||
className="marketCard"
|
||
title={
|
||
<MarketHeader
|
||
market={symbol}
|
||
markets={markets}
|
||
onMarketChange={setSymbol}
|
||
leftSlot={
|
||
<label className="inlineField">
|
||
<span className="inlineField__label">Source</span>
|
||
<input
|
||
className="inlineField__input"
|
||
value={source}
|
||
onChange={(e) => setSource(e.target.value)}
|
||
placeholder="(any)"
|
||
/>
|
||
</label>
|
||
}
|
||
stats={stats}
|
||
rightSlot={
|
||
<div className="marketHeader__actions">
|
||
<label className="inlineField">
|
||
<span className="inlineField__label">Poll</span>
|
||
<input
|
||
className="inlineField__input"
|
||
value={pollMs}
|
||
type="number"
|
||
min={250}
|
||
step={250}
|
||
onChange={(e) => setPollMs(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="inlineField">
|
||
<span className="inlineField__label">Limit</span>
|
||
<input
|
||
className="inlineField__input"
|
||
value={limit}
|
||
type="number"
|
||
min={50}
|
||
step={50}
|
||
onChange={(e) => setLimit(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
<Button onClick={() => void refresh()} disabled={loading} type="button">
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
{error ? <div className="uiError">{error}</div> : null}
|
||
</Card>
|
||
|
||
<ChartPanel
|
||
candles={candles}
|
||
indicators={indicators}
|
||
timeframe={tf}
|
||
bucketSeconds={bucketSeconds}
|
||
seriesKey={seriesKey}
|
||
onTimeframeChange={setTf}
|
||
showIndicators={showIndicators}
|
||
onToggleIndicators={() => setShowIndicators((v) => !v)}
|
||
showBuild={showBuild}
|
||
onToggleBuild={() => setShowBuild((v) => !v)}
|
||
seriesLabel={seriesLabel}
|
||
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
||
fullscreenOverride={layoutMode === 'stack'}
|
||
onToggleFullscreenOverride={() => togglePaneFullscreen('chart')}
|
||
fullscreenStyle={
|
||
layoutMode === 'stack'
|
||
? {
|
||
zIndex: stackZ.chart,
|
||
opacity: effectiveLayerOpacity.chart,
|
||
filter: `brightness(${layerBrightness.chart})`,
|
||
pointerEvents: activePane === 'chart' ? 'auto' : 'none',
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
|
||
<Card className="bottomCard">
|
||
<Tabs
|
||
items={[
|
||
{
|
||
id: 'dlob',
|
||
label: 'DLOB',
|
||
content: (
|
||
<DlobDashboard
|
||
market={symbol}
|
||
stats={dlob}
|
||
statsConnected={dlobConnected}
|
||
statsError={dlobError}
|
||
depthBands={depthBands}
|
||
depthBandsConnected={depthBandsConnected}
|
||
depthBandsError={depthBandsError}
|
||
slippageRows={slippageRows}
|
||
slippageConnected={slippageConnected}
|
||
slippageError={slippageError}
|
||
isFullscreen={layoutMode === 'stack' && activePane === 'dlob'}
|
||
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: 'costs',
|
||
label: 'Costs',
|
||
content: <ContractCostsPanel market={symbol} />,
|
||
},
|
||
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
|
||
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
||
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
||
{
|
||
id: 'orderHistory',
|
||
label: 'Order History',
|
||
content: <div className="placeholder">Order history (next)</div>,
|
||
},
|
||
{
|
||
id: 'positionHistory',
|
||
label: 'Position History',
|
||
content: <div className="placeholder">Position history (next)</div>,
|
||
},
|
||
]}
|
||
activeId={bottomTab}
|
||
onChange={setBottomTab}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
}
|
||
sidebar={
|
||
<Card
|
||
className="orderbookCard"
|
||
title={
|
||
<div className="sideHead">
|
||
<div className="sideHead__title">Orderbook</div>
|
||
<div className="sideHead__subtitle">{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}</div>
|
||
</div>
|
||
}
|
||
>
|
||
<Tabs
|
||
items={[
|
||
{
|
||
id: 'orderbook',
|
||
label: 'Orderbook',
|
||
content: (
|
||
<div className="orderbook">
|
||
<div className="orderbook__header">
|
||
<span>Price</span>
|
||
<span className="orderbook__num">Size (USD)</span>
|
||
<span className="orderbook__num">Total (USD)</span>
|
||
</div>
|
||
<div className="orderbook__rows">
|
||
{orderbook.asks.map((r) => (
|
||
<div
|
||
key={`a-${r.price}`}
|
||
className="orderbookRow orderbookRow--ask"
|
||
style={orderbookRowBarStyle(
|
||
maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0,
|
||
maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0
|
||
)}
|
||
>
|
||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||
</div>
|
||
))}
|
||
<div className="orderbookMid">
|
||
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
|
||
<span className="orderbookMid__label">mid</span>
|
||
</div>
|
||
{orderbook.bids.map((r) => (
|
||
<div
|
||
key={`b-${r.price}`}
|
||
className="orderbookRow orderbookRow--bid"
|
||
style={orderbookRowBarStyle(
|
||
maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0,
|
||
maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0
|
||
)}
|
||
>
|
||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||
</div>
|
||
))}
|
||
<div className="orderbookMeta">
|
||
<div className="orderbookMeta__row">
|
||
<span className="muted">Spread</span>
|
||
<span className="orderbookMeta__val">
|
||
{liquidity.spreadAbs == null || liquidity.spreadPct == null
|
||
? '—'
|
||
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
|
||
</span>
|
||
</div>
|
||
<div className="orderbookMeta__row">
|
||
<span className="muted">Liquidity (L1–L10)</span>
|
||
<span className="orderbookMeta__val">
|
||
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
|
||
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
|
||
</span>
|
||
</div>
|
||
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
|
||
<div className="liquidityBar__bid" />
|
||
<div className="liquidityBar__ask" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: 'trades',
|
||
label: 'Recent Trades',
|
||
content: (
|
||
<div className="trades">
|
||
<div className="trades__header">
|
||
<span>Time</span>
|
||
<span className="trades__num">Price</span>
|
||
<span className="trades__num">Size</span>
|
||
</div>
|
||
<div className="trades__rows">
|
||
{trades.map((t) => (
|
||
<div key={`${t.time}-${t.price}`} className="tradeRow">
|
||
<span className="tradeRow__time">
|
||
{new Date(t.time * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||
</span>
|
||
<span className={['tradeRow__price', t.side === 'buy' ? 'pos' : 'neg'].join(' ')}>
|
||
{formatQty(t.price, 3)}
|
||
</span>
|
||
<span className="tradeRow__num">{t.size == null ? '—' : formatQty(t.size, 2)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
activeId={tab}
|
||
onChange={setTab}
|
||
/>
|
||
</Card>
|
||
}
|
||
rightbar={
|
||
<Card
|
||
className="tradeFormCard"
|
||
title={
|
||
<div className="tradeFormHead">
|
||
<div className="tradeFormHead__left">
|
||
<button className="chipBtn" type="button">
|
||
Cross
|
||
</button>
|
||
<button className="chipBtn" type="button">
|
||
20x
|
||
</button>
|
||
</div>
|
||
<div className="tradeFormHead__right">{symbol}</div>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="tradeForm">
|
||
<div className="segmented">
|
||
<button
|
||
className={['segmented__btn', tradeSide === 'long' ? 'segmented__btn--activeLong' : ''].filter(Boolean).join(' ')}
|
||
type="button"
|
||
onClick={() => setTradeSide('long')}
|
||
>
|
||
Long
|
||
</button>
|
||
<button
|
||
className={['segmented__btn', tradeSide === 'short' ? 'segmented__btn--activeShort' : ''].filter(Boolean).join(' ')}
|
||
type="button"
|
||
onClick={() => setTradeSide('short')}
|
||
>
|
||
Short
|
||
</button>
|
||
</div>
|
||
|
||
<div className="tradeTabs">
|
||
<button
|
||
type="button"
|
||
className={['tradeTabs__btn', tradeOrderType === 'market' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
|
||
onClick={() => setTradeOrderType('market')}
|
||
>
|
||
Market
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={['tradeTabs__btn', tradeOrderType === 'limit' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
|
||
onClick={() => setTradeOrderType('limit')}
|
||
>
|
||
Limit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={['tradeTabs__btn', tradeOrderType === 'other' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
|
||
onClick={() => setTradeOrderType('other')}
|
||
>
|
||
Others
|
||
</button>
|
||
</div>
|
||
|
||
<div className="tradeFields">
|
||
<label className="formField">
|
||
<span className="formField__label">Price</span>
|
||
<input
|
||
className="formField__input"
|
||
value={tradeOrderType === 'market' ? '' : String(tradePrice)}
|
||
placeholder={tradeOrderType === 'market' ? formatQty(latest?.close ?? null, 3) : '0'}
|
||
disabled={tradeOrderType !== 'limit'}
|
||
onChange={(e) => setTradePrice(Number(e.target.value))}
|
||
inputMode="decimal"
|
||
/>
|
||
</label>
|
||
<label className="formField">
|
||
<span className="formField__label">Size</span>
|
||
<input
|
||
className="formField__input"
|
||
value={String(tradeSize)}
|
||
onChange={(e) => setTradeSize(Number(e.target.value))}
|
||
inputMode="decimal"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<Button className="tradeCta" type="button" disabled>
|
||
Enable Trading
|
||
</Button>
|
||
|
||
<div className="tradeMeta">
|
||
<div className="tradeMeta__row">
|
||
<span className="tradeMeta__label">Order Value</span>
|
||
<span className="tradeMeta__value">{effectiveTradePrice ? formatUsd(effectiveTradePrice * tradeSize) : '—'}</span>
|
||
</div>
|
||
<div className="tradeMeta__row">
|
||
<span className="tradeMeta__label">Slippage (Dynamic)</span>
|
||
<span className="tradeMeta__value">
|
||
{slippageError ? (
|
||
<span className="neg">{slippageError}</span>
|
||
) : dynamicSlippage?.impactBps == null ? (
|
||
slippageConnected ? (
|
||
'—'
|
||
) : (
|
||
'offline'
|
||
)
|
||
) : (
|
||
<>
|
||
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
|
||
<span className="muted">
|
||
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
|
||
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
|
||
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
|
||
: ''}
|
||
</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="tradeMeta__row">
|
||
<span className="tradeMeta__label">Margin Required</span>
|
||
<span className="tradeMeta__value">—</span>
|
||
</div>
|
||
<div className="tradeMeta__row">
|
||
<span className="tradeMeta__label">Liq. Price</span>
|
||
<span className="tradeMeta__value">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
}
|
||
/>
|
||
|
||
{layoutMode === 'stack' ? (
|
||
<>
|
||
<div className="stackDrawerHotspot" onMouseEnter={openStackPanel} aria-label="Layers hotspot" />
|
||
<Card
|
||
className={['stackDrawer', stackPanelOpen ? 'stackDrawer--open' : 'stackDrawer--closed'].join(' ')}
|
||
title="Layers"
|
||
right={
|
||
<div className="stackPanel__actions">
|
||
<span className="muted stackPanel__hint">Esc ×2</span>
|
||
<Button
|
||
size="sm"
|
||
variant={stackPanelLocked ? 'primary' : 'ghost'}
|
||
onClick={() => {
|
||
setStackPanelLocked((v) => !v);
|
||
openStackPanel();
|
||
}}
|
||
type="button"
|
||
title={stackPanelLocked ? 'Auto-hide disabled' : 'Auto-hide enabled'}
|
||
>
|
||
{stackPanelLocked ? 'Locked' : 'Auto'}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
setStackPanelOpen(false);
|
||
scheduleHideStackPanel();
|
||
}}
|
||
type="button"
|
||
>
|
||
Hide
|
||
</Button>
|
||
<Button size="sm" variant="primary" onClick={exitStack} type="button">
|
||
Back
|
||
</Button>
|
||
</div>
|
||
}
|
||
onMouseEnter={openStackPanel}
|
||
onMouseLeave={scheduleHideStackPanel}
|
||
>
|
||
<div className="stackPanel__sub muted">UI Opacity</div>
|
||
<div className="stackPanel__sliders" style={{ marginBottom: 14 }}>
|
||
<label className="stackPanel__sliderRow">
|
||
<span className="stackPanel__sliderLabel muted">Backdrop</span>
|
||
<input
|
||
className="stackPanel__slider"
|
||
type="range"
|
||
min={0}
|
||
max={95}
|
||
step={1}
|
||
value={Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}
|
||
onChange={(e) => setStackBackdropOpacity(Number(e.target.value) / 100)}
|
||
onWheel={(e) => {
|
||
e.preventDefault();
|
||
const cur = Math.round(clampNumber(stackBackdropOpacity, 0, 0.95) * 100);
|
||
const step = e.shiftKey ? 5 : 1;
|
||
const next = clampNumber(cur + stepByWheel(e, step), 0, 95);
|
||
setStackBackdropOpacity(next / 100);
|
||
}}
|
||
/>
|
||
<span className="stackPanel__sliderValue muted">
|
||
{Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}%
|
||
</span>
|
||
</label>
|
||
<label className="stackPanel__sliderRow">
|
||
<span className="stackPanel__sliderLabel muted">Panel</span>
|
||
<input
|
||
className="stackPanel__slider"
|
||
type="range"
|
||
min={5}
|
||
max={100}
|
||
step={1}
|
||
value={Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}
|
||
onChange={(e) => setStackDrawerOpacity(Number(e.target.value) / 100)}
|
||
onWheel={(e) => {
|
||
e.preventDefault();
|
||
const cur = Math.round(clampNumber(stackDrawerOpacity, 0.05, 1) * 100);
|
||
const step = e.shiftKey ? 5 : 1;
|
||
const next = clampNumber(cur + stepByWheel(e, step), 5, 100);
|
||
setStackDrawerOpacity(next / 100);
|
||
}}
|
||
/>
|
||
<span className="stackPanel__sliderValue muted">
|
||
{Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}%
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="stackPanel__sub muted">
|
||
Drag to reorder, click to focus (top = active).
|
||
</div>
|
||
<div className="stackPanel__list" role="list">
|
||
{stackTopFirst.map((pane) => {
|
||
const isActive = pane === activePane;
|
||
const label =
|
||
pane === 'chart'
|
||
? 'Chart'
|
||
: pane === 'dlob'
|
||
? 'DLOB'
|
||
: pane === 'costsNew'
|
||
? 'Costs (New)'
|
||
: 'Costs (Active)';
|
||
const pct = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100);
|
||
const isVisible = layerVisible[pane];
|
||
const isLocked = layerLocked[pane];
|
||
const brightPct = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100);
|
||
return (
|
||
<div
|
||
key={pane}
|
||
role="listitem"
|
||
className={[
|
||
'stackPanel__item',
|
||
isActive ? 'stackPanel__item--active' : null,
|
||
isLocked ? 'stackPanel__item--locked' : null,
|
||
!isVisible ? 'stackPanel__item--hidden' : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
draggable={!isLocked}
|
||
onClick={() => {
|
||
if (isLocked) return;
|
||
enterStack(pane);
|
||
}}
|
||
onDragStart={(e) => {
|
||
if (isLocked) return;
|
||
e.dataTransfer.setData('text/plain', pane);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}}
|
||
onDragOver={(e) => {
|
||
if (isLocked) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
const dragged = e.dataTransfer.getData('text/plain') as PaneId;
|
||
if (!ALL_PANES.includes(dragged)) return;
|
||
if (layerLocked[dragged]) return;
|
||
if (isLocked) return;
|
||
const nextTop = reorderList(stackTopFirst, dragged, pane);
|
||
setStackOrder(nextTop.slice().reverse() as any);
|
||
setStackOrderManual(true);
|
||
}}
|
||
>
|
||
<span className="stackPanel__drag">⋮⋮</span>
|
||
<span className="stackPanel__label">{label}</span>
|
||
<button
|
||
type="button"
|
||
className="stackPanel__iconBtn"
|
||
title={isVisible ? 'Hide layer' : 'Show layer'}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), [pane]: !isVisible }));
|
||
}}
|
||
>
|
||
{isVisible ? '👁' : '🚫'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="stackPanel__iconBtn"
|
||
title={isLocked ? 'Unlock layer' : 'Lock layer'}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setLayerLocked((prev) => ({ ...normalizeLayerToggle(prev, false), [pane]: !isLocked }));
|
||
}}
|
||
>
|
||
{isLocked ? '🔒' : '🔓'}
|
||
</button>
|
||
<input
|
||
className="stackPanel__layerBrightness"
|
||
type="range"
|
||
min={60}
|
||
max={180}
|
||
step={1}
|
||
value={brightPct}
|
||
title={`${label} brightness`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onWheel={(e) => {
|
||
e.preventDefault();
|
||
const cur = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100);
|
||
const step = e.shiftKey ? 10 : 2;
|
||
const next = clampNumber(cur + stepByWheel(e, step), 60, 180);
|
||
setLayerBrightness((prev) => ({ ...normalizeLayerFactor(prev, 1, 0.6, 1.8), [pane]: next / 100 }));
|
||
}}
|
||
disabled={isLocked}
|
||
onChange={(e) =>
|
||
setLayerBrightness((prev) => ({
|
||
...normalizeLayerFactor(prev, 1, 0.6, 1.8),
|
||
[pane]: Number(e.target.value) / 100,
|
||
}))
|
||
}
|
||
/>
|
||
<span className="stackPanel__layerBrightnessValue muted">{brightPct}%</span>
|
||
<input
|
||
className="stackPanel__layerOpacity"
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
value={pct}
|
||
title={`${label} opacity`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onWheel={(e) => {
|
||
e.preventDefault();
|
||
const cur = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100);
|
||
const step = e.shiftKey ? 5 : 1;
|
||
const next = clampNumber(cur + stepByWheel(e, step), 0, 100);
|
||
setLayerOpacity((prev) => ({ ...normalizeLayerOpacity(prev), [pane]: next / 100 }));
|
||
}}
|
||
disabled={isLocked}
|
||
onChange={(e) =>
|
||
setLayerOpacity((prev) => ({
|
||
...normalizeLayerOpacity(prev),
|
||
[pane]: Number(e.target.value) / 100,
|
||
}))
|
||
}
|
||
/>
|
||
<span className="stackPanel__layerOpacityValue muted">{pct}%</span>
|
||
{isActive ? <span className="stackPanel__badge">active</span> : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
|
||
{layoutMode === 'stack' ? <div className="stackBackdrop" aria-hidden="true" /> : null}
|
||
|
||
{layoutMode === 'stack' && effectiveLayerOpacity.dlob > 0 ? (
|
||
<div
|
||
className="stackLayer stackLayer--dlob"
|
||
role="dialog"
|
||
aria-label="DLOB layer"
|
||
style={{
|
||
zIndex: stackZ.dlob,
|
||
opacity: effectiveLayerOpacity.dlob,
|
||
filter: `brightness(${layerBrightness.dlob})`,
|
||
pointerEvents: activePane === 'dlob' ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<div className="stackLayer__body">
|
||
<Card className="stackLayer__card">
|
||
<DlobDashboard
|
||
market={symbol}
|
||
stats={dlob}
|
||
statsConnected={dlobConnected}
|
||
statsError={dlobError}
|
||
depthBands={depthBands}
|
||
depthBandsConnected={depthBandsConnected}
|
||
depthBandsError={depthBandsError}
|
||
slippageRows={slippageRows}
|
||
slippageConnected={slippageConnected}
|
||
slippageError={slippageError}
|
||
isFullscreen
|
||
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{layoutMode === 'stack' && effectiveLayerOpacity.costsActive > 0 && hasActiveContract ? (
|
||
<div
|
||
className="stackLayer stackLayer--costsActive"
|
||
role="dialog"
|
||
aria-label="Costs active layer"
|
||
style={{
|
||
zIndex: stackZ.costsActive,
|
||
opacity: effectiveLayerOpacity.costsActive,
|
||
filter: `brightness(${layerBrightness.costsActive})`,
|
||
pointerEvents: activePane === 'costsActive' ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<div className="stackLayer__body">
|
||
<Card className="stackLayer__card">
|
||
<ContractCostsPanel market={symbol} view="active" />
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{layoutMode === 'stack' && effectiveLayerOpacity.costsNew > 0 ? (
|
||
<div
|
||
className="stackLayer stackLayer--costsNew"
|
||
role="dialog"
|
||
aria-label="Costs new layer"
|
||
style={{
|
||
zIndex: stackZ.costsNew,
|
||
opacity: effectiveLayerOpacity.costsNew,
|
||
filter: `brightness(${layerBrightness.costsNew})`,
|
||
pointerEvents: activePane === 'costsNew' ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<div className="stackLayer__body">
|
||
<Card className="stackLayer__card">
|
||
<ContractCostsPanel market={symbol} view="new" />
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|