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
This commit is contained in:
@@ -1,169 +1,136 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import type { DualSurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
|
import type { SurfaceData, ImbalanceMetrics } from "@/lib/depth-map-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order Book Depth Map — Plotly.js 3D Subplots
|
* Order Book Depth Map — Plotly.js 3D Surface
|
||||||
*
|
*
|
||||||
* Two synchronized 3D surfaces side by side:
|
* Single unified surface: X = distance from mid (bps, -50 to +50),
|
||||||
* Left: BID depth (negative bps, green-warm colorscale)
|
* Y = snapshot index (oldest → newest), Z = resting size (BTC).
|
||||||
* Right: ASK depth (positive bps, red-warm colorscale)
|
|
||||||
*
|
*
|
||||||
* Features:
|
* Warm amber/gold colorscale on dark background.
|
||||||
* - Synchronized camera across both subplots using scene anchors
|
* Live imbalance overlay with formula + wall detection.
|
||||||
* - Independent colorbars per side (green for bids, red for asks)
|
|
||||||
* - Live imbalance overlay with formula + wall detection
|
|
||||||
* - Contour projection on Z=0 plane
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dual: DualSurfaceData | null;
|
surface: SurfaceData | null;
|
||||||
metrics: ImbalanceMetrics | null;
|
metrics: ImbalanceMetrics | null;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BID_COLORSCALE = [
|
const COLORSCALE = [
|
||||||
[0, "rgb(5,10,25)"],
|
[0, "rgb(8,8,18)"],
|
||||||
[0.25, "rgb(10,30,70)"],
|
[0.2, "rgb(18,18,48)"],
|
||||||
[0.5, "rgb(20,60,120)"],
|
[0.4, "rgb(50,25,90)"],
|
||||||
[0.7, "rgb(40,130,80)"],
|
[0.6, "rgb(140,60,30)"],
|
||||||
[0.85, "rgb(80,200,60)"],
|
[0.75, "rgb(210,110,30)"],
|
||||||
[0.95, "rgb(150,240,100)"],
|
[0.88, "rgb(245,170,45)"],
|
||||||
[1, "rgb(200,255,160)"],
|
[0.96, "rgb(255,220,100)"],
|
||||||
|
[1, "rgb(255,245,190)"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const ASK_COLORSCALE = [
|
export function DepthMapPlotly({ surface, metrics, height = 440 }: Props) {
|
||||||
[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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const plotlyRef = useRef<any>(null);
|
const plotlyReady = useRef(false);
|
||||||
const revisionRef = useRef(0);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const initRef = useRef(false);
|
|
||||||
|
|
||||||
// Load Plotly once
|
// Load Plotly CDN once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((window as any).Plotly) return;
|
if ((window as any).Plotly) {
|
||||||
const script = document.createElement("script");
|
setLoaded(true);
|
||||||
script.src = "https://cdn.plot.ly/plotly-3.0.0.min.js";
|
return;
|
||||||
script.async = true;
|
}
|
||||||
script.onload = () => revisionRef.current++;
|
const s = document.createElement("script");
|
||||||
document.head.appendChild(script);
|
s.src = "https://cdn.plot.ly/plotly-3.0.0.min.js";
|
||||||
return () => { script.remove(); };
|
s.async = true;
|
||||||
|
s.onload = () => setLoaded(true);
|
||||||
|
document.head.appendChild(s);
|
||||||
|
return () => { s.remove(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Render subplots
|
// Render / update chart
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !dual || !(window as any).Plotly) return;
|
if (!containerRef.current || !loaded || !surface) return;
|
||||||
const Plotly = (window as any).Plotly;
|
const Plotly = (window as any).Plotly;
|
||||||
|
if (!Plotly) return;
|
||||||
|
|
||||||
const bidTrace: any = {
|
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",
|
type: "surface",
|
||||||
x: dual.bid.x,
|
x: surface.x,
|
||||||
y: dual.bid.y,
|
y: surface.y,
|
||||||
z: dual.bid.z,
|
z: cleanZ,
|
||||||
colorscale: BID_COLORSCALE,
|
colorscale: COLORSCALE,
|
||||||
colorbar: {
|
|
||||||
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: {
|
contours: {
|
||||||
z: { show: true, usecolormap: true, highlightcolor: "rgba(100,255,100,0.2)", project: { z: true } },
|
z: {
|
||||||
|
show: true,
|
||||||
|
usecolormap: true,
|
||||||
|
highlightcolor: "rgba(255,255,255,0.25)",
|
||||||
|
project: { z: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
lighting: { ambient: 0.55, diffuse: 0.6, specular: 0.3, roughness: 0.5, fresnel: 0.2 },
|
lighting: {
|
||||||
lightposition: { x: -100, y: 200, z: 200 },
|
ambient: 0.5,
|
||||||
scene: "scene",
|
diffuse: 0.7,
|
||||||
name: "Bids",
|
specular: 0.25,
|
||||||
|
roughness: 0.45,
|
||||||
|
fresnel: 0.15,
|
||||||
|
},
|
||||||
|
lightposition: { x: 150, y: 250, z: 350 },
|
||||||
showscale: true,
|
showscale: true,
|
||||||
};
|
|
||||||
|
|
||||||
const askTrace: any = {
|
|
||||||
type: "surface",
|
|
||||||
x: dual.ask.x,
|
|
||||||
y: dual.ask.y,
|
|
||||||
z: dual.ask.z,
|
|
||||||
colorscale: ASK_COLORSCALE,
|
|
||||||
colorbar: {
|
colorbar: {
|
||||||
title: { text: "ASK Size (BTC)", font: { color: "#f66", size: 10 } },
|
title: { text: "Resting Size", font: { color: "#999", size: 10 } },
|
||||||
tickfont: { color: "#888", size: 8 },
|
tickfont: { color: "#777", size: 8 },
|
||||||
thickness: 10,
|
thickness: 14,
|
||||||
len: 0.45,
|
len: 0.65,
|
||||||
x: 1.0,
|
x: 1.02,
|
||||||
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 = {
|
const layout: any = {
|
||||||
title: {
|
title: {
|
||||||
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
|
text: "ORDER BOOK IMBALANCE • BTC-USD-PERP",
|
||||||
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
|
font: { size: 12, color: "#ccc", family: "Inter, sans-serif" },
|
||||||
x: 0.02,
|
x: 0.03,
|
||||||
y: 0.98,
|
y: 0.98,
|
||||||
},
|
},
|
||||||
paper_bgcolor: "rgba(0,0,0,0)",
|
paper_bgcolor: "rgba(0,0,0,0)",
|
||||||
plot_bgcolor: "rgba(0,0,0,0)",
|
plot_bgcolor: "rgba(0,0,0,0)",
|
||||||
grid: { rows: 1, columns: 2, pattern: "independent", roworder: "top to bottom" },
|
scene: {
|
||||||
scene: sceneLayout("BID DEPTH (Liquidity Wall)", "bps from Mid →"),
|
xaxis: {
|
||||||
scene2: sceneLayout("ASK DEPTH (Sell Pressure)", "← bps from Mid"),
|
title: { text: "Distance from Mid (bps)", font: { size: 9, color: "#666" } },
|
||||||
margin: { l: 0, r: 10, t: 30, b: 0 },
|
gridcolor: "rgba(255,255,255,0.04)",
|
||||||
uirevision: `obi-subplots-v1`,
|
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,
|
autosize: true,
|
||||||
font: { color: "#888" },
|
font: { color: "#888" },
|
||||||
};
|
};
|
||||||
@@ -175,71 +142,59 @@ export function DepthMapPlotly({ dual, metrics, height = 440 }: Props) {
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync both scene cameras when one moves
|
if (plotlyReady.current) {
|
||||||
const syncCameras = () => {
|
Plotly.react(containerRef.current, [trace], layout, config);
|
||||||
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, [bidTrace, askTrace], layout, config).then(syncCameras);
|
|
||||||
} else {
|
} else {
|
||||||
Plotly.newPlot(containerRef.current, [bidTrace, askTrace], layout, config).then(syncCameras);
|
Plotly.newPlot(containerRef.current, [trace], layout, config);
|
||||||
plotlyRef.current = true;
|
plotlyReady.current = true;
|
||||||
}
|
}
|
||||||
}, [dual]);
|
}, [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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div ref={containerRef} style={{ width: "100%", height }} />
|
<div ref={containerRef} style={{ width: "100%", height }} />
|
||||||
|
|
||||||
{/* Imbalance Overlay — placed between the two subplots */}
|
{/* Imbalance Overlay */}
|
||||||
{metrics && (
|
{metrics && (
|
||||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 flex flex-col gap-2 pointer-events-none">
|
<div className="absolute top-3 right-4 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">
|
<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-0.5">Live Imbalance</p>
|
<p className="text-[8px] text-muted-foreground uppercase tracking-widest mb-1">Live Imbalance</p>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-xl font-mono font-bold ${metrics.imbalance > 0 ? "text-green-400" : "text-red-400"}`}>
|
<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)}
|
{metrics.imbalance > 0 ? "+" : ""}{metrics.imbalance.toFixed(3)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{metrics.wallSide !== "none" && (
|
{metrics.wallSide !== "none" && (
|
||||||
<span className={`text-[9px] ${metrics.wallSide === "bid" ? "text-green-400/80" : "text-red-400/80"}`}>
|
<p className={`text-[9px] mt-0.5 ${metrics.wallSide === "bid" ? "text-green-400/80" : "text-red-400/80"}`}>
|
||||||
Wall stacked on {metrics.wallSide === "bid" ? "BIDS ▲" : "ASKS ▼"}
|
Wall: {metrics.wallSide.toUpperCase()}S ({(metrics.wallStrength ?? 0).toFixed(1)})
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* Gauge bar */}
|
<div className="w-full h-1 bg-white/10 rounded-full mt-1.5 overflow-hidden">
|
||||||
<div className="w-32 h-1 bg-white/10 rounded-full mt-1.5 mx-auto overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${metrics.imbalance >= 0 ? "bg-green-500" : "bg-red-500"}`}
|
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}%` }}
|
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>
|
</div>
|
||||||
<div className="bg-black/75 backdrop-blur-lg rounded-lg px-3 py-1.5 border border-white/10 text-center">
|
<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 leading-relaxed">
|
<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>)
|
I = (V<sub>b</sub> − V<sub>a</sub>) / (V<sub>b</sub> + V<sub>a</sub>)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { DepthMapPlotly } from "@/components/depth-map-plotly";
|
import { DepthMapPlotly } from "@/components/depth-map-plotly";
|
||||||
import { EquityChart } from "@/components/equity-chart";
|
import { EquityChart } from "@/components/equity-chart";
|
||||||
import {
|
import {
|
||||||
type L2Snapshot, type DualSurfaceData, type ImbalanceMetrics,
|
type L2Snapshot, type SurfaceData, type ImbalanceMetrics,
|
||||||
L2RingBuffer, l2SnapshotsToDualSurface, computeImbalance, generateSyntheticSnapshots,
|
L2RingBuffer, l2SnapshotsToSurface, computeImbalance, generateSyntheticSnapshots,
|
||||||
} from "@/lib/depth-map-utils";
|
} from "@/lib/depth-map-utils";
|
||||||
import type { Strategy, Trade, LiveMetrics } from "@/lib/types";
|
import type { Strategy, Trade, LiveMetrics } from "@/lib/types";
|
||||||
import { Activity } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
@@ -45,8 +45,8 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData
|
|||||||
|
|
||||||
// Compute dual surface + metrics
|
// Compute dual surface + metrics
|
||||||
const snapsNow = ringBuffer.current.snapshot();
|
const snapsNow = ringBuffer.current.snapshot();
|
||||||
const dualNow: DualSurfaceData | null = snapsNow.length >= 3
|
const surfaceNow: SurfaceData | null = snapsNow.length >= 3
|
||||||
? l2SnapshotsToDualSurface(snapsNow, 50, 50)
|
? l2SnapshotsToSurface(snapsNow, 50, 60)
|
||||||
: null;
|
: null;
|
||||||
const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0
|
const metricsNow: ImbalanceMetrics | null = snapsNow.length > 0
|
||||||
? computeImbalance(snapsNow[snapsNow.length - 1])
|
? computeImbalance(snapsNow[snapsNow.length - 1])
|
||||||
@@ -83,7 +83,7 @@ export function OBIDetail({ strategy, strategyName, equityData, trades, liveData
|
|||||||
|
|
||||||
{/* 3D Subplots: Bid (left) + Ask (right) */}
|
{/* 3D Subplots: Bid (left) + Ask (right) */}
|
||||||
<Card className="overflow-hidden border-border">
|
<Card className="overflow-hidden border-border">
|
||||||
<DepthMapPlotly dual={dualNow} metrics={metricsNow} height={440} />
|
<DepthMapPlotly surface={surfaceNow} metrics={metricsNow} height={440} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Metrics Row */}
|
{/* Metrics Row */}
|
||||||
|
|||||||
Reference in New Issue
Block a user