"use client"; import { createContext, useContext, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { NumberTicker } from "@/components/motion/number-ticker"; import { Tooltip } from "@/components/motion/tooltip"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; import { buildLiquidityHeatmap, pricePosition, type LiquiditySnapshot, } from "./liquidity-heatmap/model"; const formatPrice = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 2 }); const formatSize = (value: number) => value.toLocaleString("en-US", { notation: "compact", maximumFractionDigits: 1 }); const colors = ["#17233b", "#254f80", "#217c90", "#27af98", "#97d56b", "#f4df76"]; function heatColor(value: number, max: number) { return colors[ Math.min(colors.length - 1, Math.floor((max > 0 ? value / max : 0) * colors.length)) ]; } interface LiquidityHeatmapProps { /** Time-ordered snapshots; use consistent price buckets and stable IDs. */ snapshots: readonly LiquiditySnapshot[]; /** Fixed intensity ceiling keeps colors comparable across live updates. */ maxSize?: number; unit?: string; label?: string; formatPrice?: (price: number) => string; formatSize?: (size: number) => string; children?: ReactNode; className?: string; } const Context = createContext< | (ReturnType & { ceiling: number; unit: string; formatPrice: (value: number) => string; formatSize: (value: number) => string; }) | null >(null); export function useLiquidityHeatmap() { const value = useContext(Context); if (!value) throw new Error("Liquidity heatmap parts must be inside LiquidityHeatmap"); return value; } export function LiquidityHeatmap({ snapshots, maxSize, unit = "units", label = "Liquidity heatmap", formatPrice: priceFormatter = formatPrice, formatSize: sizeFormatter = formatSize, children, className, }: LiquidityHeatmapProps) { const model = useMemo(() => buildLiquidityHeatmap(snapshots), [snapshots]); const ceiling = maxSize !== undefined && Number.isFinite(maxSize) && maxSize > 0 ? maxSize : model.maximum; return (
{children ?? ( <> )}
); } export function LiquidityHeatmapPlot({ className }: { className?: string }) { const { columns, prices, ceiling, unit, formatPrice, formatSize } = useLiquidityHeatmap(); const reduced = useReducedMotion(); const root = useRef(null); const anchor = useRef(null); const tooltipId = useId(); const [active, setActive] = useState(null); const [cursor, setCursor] = useState(null); const keys = prices.flatMap((price) => columns.map((column) => JSON.stringify([column.id, price])), ); const validCursor = cursor !== null && keys.includes(cursor) ? cursor : keys[0]; if (cursor !== null && !keys.includes(cursor)) setCursor(null); if (active !== null && !keys.includes(active)) setActive(null); const activeIndex = active === null ? -1 : keys.indexOf(active); const row = Math.floor(activeIndex / columns.length); const col = activeIndex % columns.length; const selected = activeIndex >= 0 ? columns[col] : null; const size = selected?.levels.get(prices[row]); let connected = false; const path = columns .map((column, index) => { const y = column.price === undefined ? null : pricePosition(column.price, prices); if (y === null) { connected = false; return ""; } const command = connected ? "L" : "M"; connected = true; return `${command}${index + 0.5},${y}`; }) .join(" "); if (!prices.length || !columns.length) return (

No liquidity data

); return (
{columns.map((column) => ( ))} {prices.map((price, rowIndex) => ( {columns.map((column, columnIndex) => { const value = column.levels.get(price); const key = JSON.stringify([column.id, price]); const index = rowIndex * columns.length + columnIndex; return ( ); })} ))}
{column.label}
{/* Remount only when trace topology changes; numeric coordinates otherwise interpolate together. */} `${column.id}:${column.price !== undefined && pricePosition(column.price, prices) !== null}`, ) .join("|")} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" viewBox={`0 0 ${columns.length} ${prices.length}`} preserveAspectRatio="none" >
{ if (!open) setActive(null); }} content={ selected && ( {selected.label} · {formatPrice(prices[row])} {size === undefined ? "No data" : ( formatSize(size)} suffix={` ${unit}`} duration={0.35} startOnView={false} /> )} {selected.price !== undefined && Number.isFinite(selected.price) && ( Market {formatPrice(selected.price)} )} ) } />
); } export function LiquidityHeatmapLegend({ className }: { className?: string }) { const { ceiling, formatSize, unit } = useLiquidityHeatmap(); return (
Resting liquidity · {unit} 0
); } export type { LiquiditySnapshot, LiquidityHeatmapProps };