← Back
/**
 * Depth Heatmap — Bookmap-style order book visualization overlay
 *
 * Renders a canvas overlay on the modal chart showing historical
 * order book depth as colored cells. Bids = green, Asks = red.
 * Intensity = relative notional size.
 *
 * Usage: called from mini-charts.js when modal opens/closes.
 *   depthHeatmapUI.attach(modal)  — start overlay
 *   depthHeatmapUI.detach()       — stop overlay
 *   depthHeatmapUI.toggle()       — show/hide
 */

/* eslint-disable no-unused-vars */
const depthHeatmapUI = (() => {
  'use strict'

  let _canvas = null
  let _ctx = null
  let _modal = null      // reference to mini-charts modal object
  let _enabled = false
  let _visible = false
  let _fetchTimer = null
  let _data = null        // latest heatmap data from server
  let _renderRAF = null

  const FETCH_INTERVAL = 5000   // fetch new data every 5s
  const BID_COLOR = [0, 180, 120]   // softer green
  const ASK_COLOR = [200, 60, 60]   // softer red
  const MAX_OPACITY = 0.28          // much more transparent — candles visible through
  const MIN_INTENSITY = 0.04        // skip noise below 4% of max

  /**
   * Attach heatmap overlay to modal chart
   * @param {Object} modal — the modal object from mini-charts.js
   */
  function attach(modal) {
    if (!modal || !modal.chart || !modal.currentSym) return
    _modal = modal
    _enabled = true
    _visible = false  // always off by default, user clicks button to enable

    // Create canvas overlay
    const chartEl = document.getElementById('cmChartBody')
    if (!chartEl) return

    _canvas = document.createElement('canvas')
    _canvas.id = 'depthHeatmapCanvas'
    _canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2;'
    _canvas.width = chartEl.clientWidth
    _canvas.height = chartEl.clientHeight
    chartEl.style.position = 'relative'
    chartEl.appendChild(_canvas)
    _ctx = _canvas.getContext('2d')

    // Listen for chart resize
    _modal._heatmapResizeObs = new ResizeObserver(() => {
      if (!_canvas) return
      _canvas.width = chartEl.clientWidth
      _canvas.height = chartEl.clientHeight
      scheduleRender()
    })
    _modal._heatmapResizeObs.observe(chartEl)

    // Listen for visible range changes to re-render
    if (_modal.chart && _modal.chart.timeScale) {
      _modal._heatmapRangeUnsub = _modal.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
        scheduleRender()
      })
    }

    // Start fetching
    fetchData()
    _fetchTimer = setInterval(fetchData, FETCH_INTERVAL)

    if (!_visible) hideCanvas()
  }

  /**
   * Detach and cleanup
   */
  function detach() {
    _enabled = false
    if (_fetchTimer) { clearInterval(_fetchTimer); _fetchTimer = null }
    if (_renderRAF) { cancelAnimationFrame(_renderRAF); _renderRAF = null }
    if (_modal && _modal._heatmapResizeObs) { _modal._heatmapResizeObs.disconnect(); _modal._heatmapResizeObs = null }
    if (_modal && _modal._heatmapRangeUnsub) { _modal._heatmapRangeUnsub(); _modal._heatmapRangeUnsub = null }
    if (_canvas && _canvas.parentNode) _canvas.parentNode.removeChild(_canvas)
    _canvas = null
    _ctx = null
    _modal = null
    _data = null
  }

  /**
   * Toggle visibility
   */
  function toggle() {
    _visible = !_visible
    if (_visible) {
      showCanvas()
      fetchData()
      scheduleRender()
    } else {
      hideCanvas()
    }
    return _visible
  }

  function isVisible() { return _visible }

  function hideCanvas() { if (_canvas) _canvas.style.display = 'none' }
  function showCanvas() { if (_canvas) _canvas.style.display = '' }

  function lsGet(key, def) {
    try { const v = localStorage.getItem('hm_' + key); return v !== null ? JSON.parse(v) : def } catch (_) { return def }
  }
  function lsSet(key, val) {
    try { localStorage.setItem('hm_' + key, JSON.stringify(val)) } catch (_) {}
  }

  /**
   * Fetch heatmap data from server
   */
  async function fetchData() {
    if (!_enabled || !_visible || !_modal || !_modal.currentSym) return
    try {
      const resp = await fetch(`/api/depth-heatmap?symbol=${_modal.currentSym}`)
      const json = await resp.json()
      if (json.success && json.data) {
        _data = json.data
        scheduleRender()
      }
    } catch (_) {}
  }

  function scheduleRender() {
    if (_renderRAF) return
    _renderRAF = requestAnimationFrame(() => {
      _renderRAF = null
      render()
    })
  }

  /**
   * Render heatmap cells on canvas — Bookmap-style
   * Each 5s snapshot = separate column for maximum detail
   */
  function render() {
    if (!_ctx || !_canvas || !_modal || !_modal.chart || !_modal.series || !_data || !_visible) return
    if (!_data.snapshots || !_data.snapshots.length) return

    const series = _modal.series
    const timeScale = _modal.chart.timeScale()
    const w = _canvas.width
    const h = _canvas.height

    _ctx.clearRect(0, 0, w, h)

    // Get visible time range in seconds for linear coordinate mapping
    const range = timeScale.getVisibleRange()
    if (!range) return
    const fromTs = typeof range.from === 'number' ? range.from : 0
    const toTs = typeof range.to === 'number' ? range.to : 0
    if (toTs <= fromTs) return

    // Establish px/sec ratio using two anchor points from chart
    const x1 = timeScale.timeToCoordinate(fromTs)
    const x2 = timeScale.timeToCoordinate(toTs)
    if (x1 == null || x2 == null || x2 <= x1) return
    const pxPerSec = (x2 - x1) / (toTs - fromTs)

    const bucketSize = _data.bucketSize || 1
    const snapshots = _data.snapshots

    // Cell width: calculate from actual snapshot interval (typically 10s) + slight overlap
    let snapIntervalSec = 10
    if (snapshots.length >= 2) {
      const dt = (snapshots[1].ts - snapshots[0].ts) / 1000
      if (dt > 0 && dt < 120) snapIntervalSec = dt
    }
    const cellW = Math.max(2, Math.ceil(snapIntervalSec * pxPerSec) + 1)

    // Find global max notional for normalization
    let maxNotional = 0
    for (const snap of snapshots) {
      for (const v of Object.values(snap.bids)) if (v > maxNotional) maxNotional = v
      for (const v of Object.values(snap.asks)) if (v > maxNotional) maxNotional = v
    }
    if (maxNotional <= 0) return

    // Render each snapshot as its own column (true bookmap detail)
    for (const snap of snapshots) {
      const snapSec = Math.floor(snap.ts / 1000)
      const xCoord = x1 + (snapSec - fromTs) * pxPerSec
      if (xCoord < -cellW || xCoord > w + cellW) continue

      drawSide(snap.bids, BID_COLOR, xCoord, cellW, bucketSize, maxNotional, series)
      drawSide(snap.asks, ASK_COLOR, xCoord, cellW, bucketSize, maxNotional, series)
    }
  }

  /**
   * Draw one side (bids or asks) of a snapshot
   */
  function drawSide(levels, color, xCoord, cellW, bucketSize, maxNotional, series) {
    for (const [priceStr, notional] of Object.entries(levels)) {
      const price = parseFloat(priceStr)
      if (!price || notional <= 0) continue

      // Map price to y coordinate
      const yCoord = series.priceToCoordinate(price)
      if (yCoord == null || yCoord < 0 || yCoord > _canvas.height) continue

      // Map next price bucket to get cell height (+1px overlap for seamless fill)
      const yCoord2 = series.priceToCoordinate(price + bucketSize)
      let cellH = 4 // default minimum
      if (yCoord2 != null) {
        cellH = Math.ceil(Math.abs(yCoord2 - yCoord)) + 1
      }

      // Intensity based on relative notional
      const intensity = Math.min(1, notional / maxNotional)
      if (intensity < MIN_INTENSITY) continue  // skip noise
      // Apply sqrt for better visual distribution (small walls still visible)
      const alpha = Math.sqrt(intensity) * MAX_OPACITY

      const [r, g, b] = color
      _ctx.fillStyle = `rgba(${r},${g},${b},${alpha.toFixed(3)})`
      _ctx.fillRect(xCoord - cellW / 2, yCoord - cellH / 2, cellW, cellH)
    }
  }

  return { attach, detach, toggle, isVisible }
})()

📜 Git History

dc48be8feat: depth heatmap v2 — SQLite persistence, visual polish, klines gap fix8 weeks ago
0ec6ec5feat: depth heatmap engine (Bookmap-style order book visualization)8 weeks ago
Show last diff
Loading...