5c41d232c1
- 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
206 lines
6.6 KiB
TypeScript
206 lines
6.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|