3D Order Book Depth Map: Plotly subplots (bid/ask split) + remove Three.js
- Replaced single surface with dual synchronized 3D subplots: Left: BID depth (green colorscale, -50 to 0 bps) Right: ASK depth (red colorscale, 0 to +50 bps) - Independent colorbars per side with proper labeling - Camera sync via scene anchor mirroring - Contour projection on both surfaces - Live imbalance overlay centered between subplots Data pipeline: - l2SnapshotsToDualSurface() splits bid/ask into separate matrices - L2RingBuffer unchanged (60 snapshots, O(1) append) Removed: - depth-map-three.tsx (Three.js alternative) - Engine toggle buttons from OBI detail - Three.js CDN loading
This commit is contained in:
@@ -1,45 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DualSurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
|
||||
|
||||
/**
|
||||
* Order Book Depth Map — Plotly.js 3D Surface
|
||||
*
|
||||
* Renders a professional quant-grade depth map with:
|
||||
* - 3D surface: X=distance from mid (bps), Y=snapshots, Z=resting size
|
||||
* - Color scale: warm orange/yellow peaks on dark background
|
||||
* - Live imbalance annotation
|
||||
* - Formula overlay
|
||||
* - Smooth camera controls with hover tooltips
|
||||
*
|
||||
* Performance:
|
||||
* - 100×60 grid = 6000 vertices — well within Plotly limits
|
||||
* - Update at 1-2 Hz recommended (depth maps don't need 60fps)
|
||||
* - Use uirevision to prevent camera reset on updates
|
||||
* Order Book Depth Map — Plotly.js 3D Subplots
|
||||
*
|
||||
* Two synchronized 3D surfaces side by side:
|
||||
* Left: BID depth (negative bps, green-warm colorscale)
|
||||
* Right: ASK depth (positive bps, red-warm colorscale)
|
||||
*
|
||||
* Features:
|
||||
* - Synchronized camera across both subplots using scene anchors
|
||||
* - Independent colorbars per side (green for bids, red for asks)
|
||||
* - Live imbalance overlay with formula + wall detection
|
||||
* - Contour projection on Z=0 plane
|
||||
*/
|
||||
|
||||
interface DepthMapPlotlyProps {
|
||||
surface: SurfaceData | null;
|
||||
interface Props {
|
||||
dual: DualSurfaceData | null;
|
||||
metrics: ImbalanceMetrics | null;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Inline Plotly — avoids extra npm dependency for types
|
||||
declare global {
|
||||
interface Window {
|
||||
Plotly: any;
|
||||
}
|
||||
}
|
||||
const BID_COLORSCALE = [
|
||||
[0, "rgb(5,10,25)"],
|
||||
[0.25, "rgb(10,30,70)"],
|
||||
[0.5, "rgb(20,60,120)"],
|
||||
[0.7, "rgb(40,130,80)"],
|
||||
[0.85, "rgb(80,200,60)"],
|
||||
[0.95, "rgb(150,240,100)"],
|
||||
[1, "rgb(200,255,160)"],
|
||||
];
|
||||
|
||||
export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotlyProps) {
|
||||
const ASK_COLORSCALE = [
|
||||
[0, "rgb(25,5,10)"],
|
||||
[0.25, "rgb(70,10,30)"],
|
||||
[0.5, "rgb(120,20,60)"],
|
||||
[0.7, "rgb(200,40,40)"],
|
||||
[0.85, "rgb(240,80,30)"],
|
||||
[0.95, "rgb(255,160,60)"],
|
||||
[1, "rgb(255,220,140)"],
|
||||
];
|
||||
|
||||
export function DepthMapPlotly({ dual, metrics, height = 440 }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotlyRef = useRef<any>(null);
|
||||
const revisionRef = useRef(0);
|
||||
const initRef = useRef(false);
|
||||
|
||||
// Load Plotly script once
|
||||
// Load Plotly once
|
||||
useEffect(() => {
|
||||
if (window.Plotly) return;
|
||||
if ((window as any).Plotly) return;
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.plot.ly/plotly-3.0.0.min.js";
|
||||
script.async = true;
|
||||
@@ -48,83 +60,110 @@ export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotl
|
||||
return () => { script.remove(); };
|
||||
}, []);
|
||||
|
||||
// Initialize or update chart
|
||||
// Render subplots
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !surface || !window.Plotly) return;
|
||||
if (!containerRef.current || !dual || !(window as any).Plotly) return;
|
||||
const Plotly = (window as any).Plotly;
|
||||
|
||||
const Plotly = window.Plotly;
|
||||
|
||||
const trace: any = {
|
||||
const bidTrace: any = {
|
||||
type: "surface",
|
||||
x: surface.x,
|
||||
y: surface.y,
|
||||
z: surface.z,
|
||||
colorscale: [
|
||||
[0, "rgb(10,10,20)"],
|
||||
[0.3, "rgb(20,20,60)"],
|
||||
[0.5, "rgb(60,30,100)"],
|
||||
[0.7, "rgb(180,100,30)"],
|
||||
[0.85, "rgb(240,160,40)"],
|
||||
[0.95, "rgb(255,210,80)"],
|
||||
[1, "rgb(255,240,180)"],
|
||||
],
|
||||
contours: {
|
||||
z: { show: true, usecolormap: true, highlightcolor: "rgba(255,255,255,0.3)", project: { z: true } },
|
||||
},
|
||||
lighting: {
|
||||
ambient: 0.4,
|
||||
diffuse: 0.7,
|
||||
specular: 0.3,
|
||||
roughness: 0.5,
|
||||
fresnel: 0.2,
|
||||
},
|
||||
lightposition: { x: 100, y: 200, z: 300 },
|
||||
showscale: true,
|
||||
x: dual.bid.x,
|
||||
y: dual.bid.y,
|
||||
z: dual.bid.z,
|
||||
colorscale: BID_COLORSCALE,
|
||||
colorbar: {
|
||||
title: { text: "Resting Size", font: { color: "#888", size: 10 } },
|
||||
tickfont: { color: "#666", size: 9 },
|
||||
thickness: 12,
|
||||
len: 0.6,
|
||||
x: 1.02,
|
||||
title: { text: "BID Size (BTC)", font: { color: "#6f6", size: 10 } },
|
||||
tickfont: { color: "#888", size: 8 },
|
||||
thickness: 10,
|
||||
len: 0.45,
|
||||
x: 0.46,
|
||||
y: 0.5,
|
||||
yanchor: "middle",
|
||||
},
|
||||
contours: {
|
||||
z: { show: true, usecolormap: true, highlightcolor: "rgba(100,255,100,0.2)", project: { z: true } },
|
||||
},
|
||||
lighting: { ambient: 0.55, diffuse: 0.6, specular: 0.3, roughness: 0.5, fresnel: 0.2 },
|
||||
lightposition: { x: -100, y: 200, z: 200 },
|
||||
scene: "scene",
|
||||
name: "Bids",
|
||||
showscale: true,
|
||||
};
|
||||
|
||||
const askTrace: any = {
|
||||
type: "surface",
|
||||
x: dual.ask.x,
|
||||
y: dual.ask.y,
|
||||
z: dual.ask.z,
|
||||
colorscale: ASK_COLORSCALE,
|
||||
colorbar: {
|
||||
title: { text: "ASK Size (BTC)", font: { color: "#f66", size: 10 } },
|
||||
tickfont: { color: "#888", size: 8 },
|
||||
thickness: 10,
|
||||
len: 0.45,
|
||||
x: 1.0,
|
||||
y: 0.5,
|
||||
yanchor: "middle",
|
||||
},
|
||||
contours: {
|
||||
z: { show: true, usecolormap: true, highlightcolor: "rgba(255,100,100,0.2)", project: { z: true } },
|
||||
},
|
||||
lighting: { ambient: 0.55, diffuse: 0.6, specular: 0.3, roughness: 0.5, fresnel: 0.2 },
|
||||
lightposition: { x: 100, y: 200, z: 200 },
|
||||
scene: "scene2",
|
||||
name: "Asks",
|
||||
showscale: true,
|
||||
};
|
||||
|
||||
const sceneLayout = (title: string, xTitle: string) => ({
|
||||
xaxis: {
|
||||
title: { text: xTitle, font: { size: 9, color: "#666" } },
|
||||
gridcolor: "rgba(255,255,255,0.04)",
|
||||
zerolinecolor: "rgba(255,255,255,0.1)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: "Time →", 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.4, y: 1.3, z: 0.9 },
|
||||
center: { x: 0, y: 0, z: -0.05 },
|
||||
},
|
||||
aspectmode: "manual" as const,
|
||||
aspectratio: { x: 1.2, y: 1.0, z: 0.55 },
|
||||
bgcolor: "rgba(0,0,0,0)",
|
||||
annotations: [{
|
||||
text: title,
|
||||
font: { size: 11, color: "#aaa" },
|
||||
showarrow: false,
|
||||
x: 0.5,
|
||||
y: 1.05,
|
||||
xref: "paper" as const,
|
||||
yref: "paper" as const,
|
||||
}],
|
||||
});
|
||||
|
||||
const layout: any = {
|
||||
title: {
|
||||
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
|
||||
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
|
||||
x: 0.05,
|
||||
x: 0.02,
|
||||
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.05)",
|
||||
zerolinecolor: "rgba(255,255,255,0.15)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: "Snapshots (oldest → newest)", font: { size: 9, color: "#666" } },
|
||||
gridcolor: "rgba(255,255,255,0.05)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
},
|
||||
zaxis: {
|
||||
title: { text: "Size (BTC)", font: { size: 9, color: "#666" } },
|
||||
gridcolor: "rgba(255,255,255,0.05)",
|
||||
tickfont: { size: 8, color: "#555" },
|
||||
},
|
||||
camera: {
|
||||
eye: { x: 1.5, y: 1.2, z: 1.0 },
|
||||
center: { x: 0, y: 0, z: -0.1 },
|
||||
},
|
||||
aspectmode: "manual",
|
||||
aspectratio: { x: 1.6, y: 1.0, z: 0.6 },
|
||||
bgcolor: "rgba(0,0,0,0)",
|
||||
},
|
||||
margin: { l: 0, r: 20, t: 30, b: 0 },
|
||||
uirevision: `depth-${revisionRef.current}`,
|
||||
grid: { rows: 1, columns: 2, pattern: "independent", roworder: "top to bottom" },
|
||||
scene: sceneLayout("BID DEPTH (Liquidity Wall)", "bps from Mid →"),
|
||||
scene2: sceneLayout("ASK DEPTH (Sell Pressure)", "← bps from Mid"),
|
||||
margin: { l: 0, r: 10, t: 30, b: 0 },
|
||||
uirevision: `obi-subplots-v1`,
|
||||
autosize: true,
|
||||
font: { color: "#888" },
|
||||
};
|
||||
@@ -132,65 +171,77 @@ export function DepthMapPlotly({ surface, metrics, height = 420 }: DepthMapPlotl
|
||||
const config = {
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ["sendDataToCloud", "zoom2d", "pan2d", "select2d", "lasso2d", "autoScale2d"],
|
||||
modeBarButtonsToAdd: [],
|
||||
displaylogo: false,
|
||||
responsive: true,
|
||||
} as any;
|
||||
};
|
||||
|
||||
// Sync both scene cameras when one moves
|
||||
const syncCameras = () => {
|
||||
if (!containerRef.current) return;
|
||||
const gd = containerRef.current as any;
|
||||
if (!gd._fullLayout?.scene?._scene?.camera) return;
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
// Link scene2 camera to scene1
|
||||
try {
|
||||
const s1 = gd._fullLayout.scene._scene;
|
||||
const s2 = gd._fullLayout.scene2?._scene;
|
||||
if (s1 && s2 && s1.glplot && s2.glplot) {
|
||||
// Mirror scene1 camera to scene2 on drag
|
||||
const origEye = { ...s2.camera.eye };
|
||||
const sync = () => {
|
||||
if (s2.camera.eye.x !== s1.camera.eye.x) {
|
||||
s2.camera.eye.set(-s1.camera.eye.x, s1.camera.eye.y, s1.camera.eye.z);
|
||||
s2.camera.up.set(0, 0, 1);
|
||||
s2.glplot.updateCamera();
|
||||
}
|
||||
requestAnimationFrame(sync);
|
||||
};
|
||||
sync();
|
||||
}
|
||||
} catch { /* ignore sync errors */ }
|
||||
};
|
||||
|
||||
if (plotlyRef.current) {
|
||||
Plotly.react(containerRef.current, [trace], layout, config);
|
||||
Plotly.react(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras);
|
||||
} else {
|
||||
Plotly.newPlot(containerRef.current, [trace], layout, config);
|
||||
Plotly.newPlot(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras);
|
||||
plotlyRef.current = true;
|
||||
|
||||
// Bind hover for tooltip via Plotly's on() method
|
||||
(containerRef.current as any)?.on?.("plotly_hover", (d: any) => {
|
||||
const pt = d.points?.[0];
|
||||
if (!pt) return;
|
||||
const bps = pt.x?.toFixed?.(1) ?? "?";
|
||||
const size = pt.z?.toFixed?.(4) ?? "?";
|
||||
// Custom tooltip handled by Plotly's built-in hoverlabel
|
||||
});
|
||||
}
|
||||
}, [surface]);
|
||||
}, [dual]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={containerRef} style={{ width: "100%", height }} />
|
||||
|
||||
{/* Imbalance Overlay */}
|
||||
{/* Imbalance Overlay — placed between the two subplots */}
|
||||
{metrics && (
|
||||
<div className="absolute top-3 right-4 z-10 flex flex-col gap-3">
|
||||
{/* Imbalance gauge */}
|
||||
<div className="bg-black/60 backdrop-blur rounded-lg px-3 py-2 border border-white/10">
|
||||
<p className="text-[9px] text-muted-foreground uppercase tracking-wider">Imbalance</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg font-mono font-bold ${metrics.imbalance > 0 ? "text-green-400" : "text-red-400"}`}>
|
||||
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(2)}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 flex flex-col gap-2 pointer-events-none">
|
||||
<div className="bg-black/75 backdrop-blur-lg rounded-lg px-4 py-2.5 border border-white/10 text-center">
|
||||
<p className="text-[8px] text-muted-foreground uppercase tracking-widest mb-0.5">Live Imbalance</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className={`text-xl font-mono font-bold ${metrics.imbalance > 0 ? "text-green-400" : "text-red-400"}`}>
|
||||
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)}
|
||||
</span>
|
||||
{metrics.wallSide !== "none" && (
|
||||
<span className="text-[9px] text-amber-400/80">
|
||||
Wall on {metrics.wallSide === "bid" ? "BIDS" : "ASKS"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Mini bar gauge */}
|
||||
<div className="w-full h-1.5 bg-white/10 rounded-full mt-1 overflow-hidden">
|
||||
{metrics.wallSide !== "none" && (
|
||||
<span className={`text-[9px] ${metrics.wallSide === "bid" ? "text-green-400/80" : "text-red-400/80"}`}>
|
||||
Wall stacked on {metrics.wallSide === "bid" ? "BIDS ▲" : "ASKS ▼"}
|
||||
</span>
|
||||
)}
|
||||
{/* Gauge bar */}
|
||||
<div className="w-32 h-1 bg-white/10 rounded-full mt-1.5 mx-auto overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`}
|
||||
style={{ width: `${Math.min(Math.abs(metrics.imbalance) * 500, 100)}%`, marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 500, 100) / 2}%` }}
|
||||
className={`h-full rounded-full ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`}
|
||||
style={{ width: `${Math.min(Math.abs(metrics.imbalance) * 400, 100)}%`, marginLeft: metrics.imbalance >= 0 ? "50%" : `${50 - Math.min(Math.abs(metrics.imbalance) * 400, 100) / 2}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formula */}
|
||||
<div className="bg-black/60 backdrop-blur rounded-lg px-3 py-2 border border-white/10">
|
||||
<p className="text-[9px] text-muted-foreground font-mono leading-relaxed">
|
||||
<div className="bg-black/75 backdrop-blur-lg rounded-lg px-3 py-1.5 border border-white/10 text-center">
|
||||
<p className="text-[8px] text-muted-foreground font-mono leading-relaxed">
|
||||
I = (V<sub>b</sub> − V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>)
|
||||
</p>
|
||||
<p className="text-[8px] text-muted-foreground/50 mt-0.5">
|
||||
= ({metrics.bidVolume.toFixed(1)} − {metrics.askVolume.toFixed(1)}) / {((metrics.bidVolume + metrics.askVolume)).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user