Files
ftdt-quant-lab/dashboard-next/src/components/depth-map-plotly.tsx
T
ramseshk 5c41d232c1 Fix OBI depth map: single unified 3D surface, no subplots
- Single surface spanning -50 to +50 bps (bid left, ask right)
- Clean warm amber/gold colorscale with contour projection
- NaN/Infinity filtering on Z matrix for clean rendering
- ResizeObserver for responsive canvas sizing
- uirevision v2 for stable camera across updates
- Removed dual-subplot approach (single colorbar, single scene)
- Imbalance overlay: +0.051 style with wall detection + formula
- L2RingBuffer unchanged, l2SnapshotsToSurface produces 60-row matrix
2026-08-05 06:30:40 +00:00

206 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
/**
* Order Book Depth Map — Plotly.js 3D Surface
*
* Single unified surface: X = distance from mid (bps, -50 to +50),
* Y = snapshot index (oldest → newest), Z = resting size (BTC).
*
* Warm amber/gold colorscale on dark background.
* Live imbalance overlay with formula + wall detection.
*/
interface Props {
surface: SurfaceData | null;
metrics: ImbalanceMetrics | null;
height?: number;
}
const COLORSCALE = [
[0, "rgb(8,8,18)"],
[0.2, "rgb(18,18,48)"],
[0.4, "rgb(50,25,90)"],
[0.6, "rgb(140,60,30)"],
[0.75, "rgb(210,110,30)"],
[0.88, "rgb(245,170,45)"],
[0.96, "rgb(255,220,100)"],
[1, "rgb(255,245,190)"],
];
export function DepthMapPlotly({ surface, metrics, height = 440 }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const plotlyReady = useRef(false);
const [loaded, setLoaded] = useState(false);
// Load Plotly CDN once
useEffect(() => {
if ((window as any).Plotly) {
setLoaded(true);
return;
}
const s = document.createElement("script");
s.src = "https://cdn.plot.ly/plotly-3.0.0.min.js";
s.async = true;
s.onload = () => setLoaded(true);
document.head.appendChild(s);
return () => { s.remove(); };
}, []);
// Render / update chart
useEffect(() => {
if (!containerRef.current || !loaded || !surface) return;
const Plotly = (window as any).Plotly;
if (!Plotly) return;
const cw = containerRef.current.clientWidth || 800;
// Build trace — ensure no NaN/Infinity values
const cleanZ = surface.z.map(row =>
row.map(v => (isFinite(v) && v > 0 ? v : 0))
);
const trace = {
type: "surface",
x: surface.x,
y: surface.y,
z: cleanZ,
colorscale: COLORSCALE,
contours: {
z: {
show: true,
usecolormap: true,
highlightcolor: "rgba(255,255,255,0.25)",
project: { z: true },
},
},
lighting: {
ambient: 0.5,
diffuse: 0.7,
specular: 0.25,
roughness: 0.45,
fresnel: 0.15,
},
lightposition: { x: 150, y: 250, z: 350 },
showscale: true,
colorbar: {
title: { text: "Resting Size", font: { color: "#999", size: 10 } },
tickfont: { color: "#777", size: 8 },
thickness: 14,
len: 0.65,
x: 1.02,
},
};
const layout: any = {
title: {
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
x: 0.03,
y: 0.98,
},
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
scene: {
xaxis: {
title: { text: "Distance from Mid (bps)", font: { size: 9, color: "#666" } },
gridcolor: "rgba(255,255,255,0.04)",
zerolinecolor: "rgba(255,255,255,0.12)",
tickfont: { size: 8, color: "#555" },
range: [-55, 55],
},
yaxis: {
title: { text: "Snapshot Index (oldest → newest)", font: { size: 9, color: "#666" } },
gridcolor: "rgba(255,255,255,0.04)",
tickfont: { size: 8, color: "#555" },
},
zaxis: {
title: { text: "Size (BTC)", font: { size: 9, color: "#666" } },
gridcolor: "rgba(255,255,255,0.04)",
tickfont: { size: 8, color: "#555" },
},
camera: {
eye: { x: 1.5, y: 1.2, z: 0.95 },
center: { x: 0, y: 0, z: -0.08 },
},
aspectmode: "manual",
aspectratio: { x: 1.5, y: 1.0, z: 0.55 },
bgcolor: "rgba(0,0,0,0)",
},
margin: { l: 0, r: 30, t: 32, b: 0 },
uirevision: "obi-surface-v2",
autosize: true,
font: { color: "#888" },
};
const config = {
displayModeBar: true,
modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"],
displaylogo: false,
responsive: true,
};
if (plotlyReady.current) {
Plotly.react(containerRef.current, [trace], layout, config);
} else {
Plotly.newPlot(containerRef.current, [trace], layout, config);
plotlyReady.current = true;
}
}, [surface, loaded]);
// Resize on container width change
useEffect(() => {
const obs = new ResizeObserver(() => {
const Plotly = (window as any).Plotly;
if (containerRef.current && Plotly) {
Plotly.Plots.resize(containerRef.current);
}
});
if (containerRef.current) obs.observe(containerRef.current);
return () => obs.disconnect();
}, []);
return (
<div className="relative">
<div ref={containerRef} style={{ width: "100%", height }} />
{/* Imbalance Overlay */}
{metrics && (
<div className="absolute top-3 right-4 z-10 flex flex-col gap-2 pointer-events-none">
<div className="bg-black/70 backdrop-blur-lg rounded-lg px-3.5 py-2.5 border border-white/10">
<p className="text-[8px] text-muted-foreground uppercase tracking-widest mb-1">Live Imbalance</p>
<div className="flex items-center gap-2">
<span
className={`text-xl font-mono font-bold ${metrics.imbalance > 0.005 ? "text-green-400" : metrics.imbalance < -0.005 ? "text-red-400" : "text-zinc-400"}`}
>
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)}
</span>
</div>
{metrics.wallSide !== "none" && (
<p className={`text-[9px] mt-0.5 ${metrics.wallSide === "bid" ? "text-green-400/80" : "text-red-400/80"}`}>
Wall: {metrics.wallSide.toUpperCase()}S ({(metrics.wallStrength ?? 0).toFixed(1)})
</p>
)}
<div className="w-full h-1 bg-white/10 rounded-full mt-1.5 overflow-hidden">
<div
className={`h-full rounded-full ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`}
style={{
width: `${Math.min(Math.abs(metrics.imbalance) * 350, 100)}%`,
marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 350, 100) / 2}%`,
}}
/>
</div>
</div>
<div className="bg-black/70 backdrop-blur-lg rounded-lg px-3 py-1.5 border border-white/10">
<p className="text-[8px] text-muted-foreground font-mono">
I = (V<sub>b</sub> V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>)
</p>
</div>
</div>
)}
</div>
);
}