← Back
/**
 * RSI / Momentum Treemap — market visualization tab.
 * Squarified treemap, sized by volume, colored by RSI or 24h%.
 * Click cell → open modal chart.
 */
const treemapUI = (() => {
  let _container = null
  let _data = []
  let _colorMode = 'rsi' // 'rsi' | 'change'
  let _refreshTimer = null
  let _loading = false
  const REFRESH_MS = 30_000

  // ── Squarified treemap layout ───────────────────────────────────
  function squarify(items, rect) {
    if (!items.length) return []
    const totalValue = items.reduce((s, i) => s + i.value, 0)
    if (totalValue <= 0) return []

    const rects = []
    const remaining = items.map(i => ({ ...i, area: (i.value / totalValue) * rect.w * rect.h }))
    let cur = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }

    function layoutRow(row, w) {
      const rowArea = row.reduce((s, r) => s + r.area, 0)
      const isHoriz = cur.w >= cur.h
      const rowLen = rowArea / (isHoriz ? cur.h : cur.w)

      let pos = 0
      for (const item of row) {
        const itemLen = item.area / rowLen
        if (isHoriz) {
          rects.push({ ...item, x: cur.x, y: cur.y + pos, w: rowLen, h: itemLen })
        } else {
          rects.push({ ...item, x: cur.x + pos, y: cur.y, w: itemLen, h: rowLen })
        }
        pos += itemLen
      }

      // Shrink remaining rect
      if (isHoriz) {
        cur = { x: cur.x + rowLen, y: cur.y, w: cur.w - rowLen, h: cur.h }
      } else {
        cur = { x: cur.x, y: cur.y + rowLen, w: cur.w, h: cur.h - rowLen }
      }
    }

    function worstRatio(row, w) {
      const s = row.reduce((a, r) => a + r.area, 0)
      const maxA = Math.max(...row.map(r => r.area))
      const minA = Math.min(...row.map(r => r.area))
      return Math.max((w * w * maxA) / (s * s), (s * s) / (w * w * minA))
    }

    let currentRow = []
    let idx = 0
    while (idx < remaining.length) {
      const shortSide = Math.min(cur.w, cur.h)
      if (shortSide <= 0) break

      const candidate = [...currentRow, remaining[idx]]
      if (currentRow.length === 0 || worstRatio(candidate, shortSide) <= worstRatio(currentRow, shortSide)) {
        currentRow.push(remaining[idx])
        idx++
      } else {
        layoutRow(currentRow, shortSide)
        currentRow = []
      }
    }
    if (currentRow.length) layoutRow(currentRow, Math.min(cur.w, cur.h))

    return rects
  }

  // ── Color mapping ──────────────────────────────────────────────
  function rsiToColor(rsi) {
    if (rsi === null || rsi === undefined) return 'rgba(80,80,100,0.7)'
    // Oversold (<30) = green, Neutral (30-70) = gray/blue, Overbought (>70) = red
    if (rsi <= 20) return 'rgba(0,200,80,0.9)'
    if (rsi <= 30) return 'rgba(40,180,80,0.8)'
    if (rsi <= 40) return 'rgba(60,140,80,0.6)'
    if (rsi <= 50) return 'rgba(80,100,120,0.5)'
    if (rsi <= 60) return 'rgba(120,100,80,0.5)'
    if (rsi <= 70) return 'rgba(160,80,60,0.6)'
    if (rsi <= 80) return 'rgba(200,60,40,0.8)'
    return 'rgba(240,40,30,0.9)'
  }

  function changeToColor(pct) {
    if (pct === null || pct === undefined) return 'rgba(80,80,100,0.7)'
    const abs = Math.abs(pct)
    const alpha = Math.min(0.9, 0.3 + abs * 0.1)
    if (pct >= 5) return `rgba(0,200,80,${alpha})`
    if (pct >= 2) return `rgba(40,180,80,${alpha})`
    if (pct >= 0.5) return `rgba(60,140,80,${alpha})`
    if (pct >= -0.5) return `rgba(80,100,120,0.4)`
    if (pct >= -2) return `rgba(200,80,60,${alpha})`
    if (pct >= -5) return `rgba(220,50,40,${alpha})`
    return `rgba(240,30,20,${alpha})`
  }

  function getColor(item) {
    return _colorMode === 'rsi' ? rsiToColor(item.rsi) : changeToColor(item.changePct)
  }

  // ── Render ─────────────────────────────────────────────────────
  function render() {
    if (!_container || !_data.length) return

    const wrapper = _container.querySelector('.tm-wrapper')
    if (!wrapper) return
    wrapper.innerHTML = ''

    const rect = wrapper.getBoundingClientRect()
    const W = rect.width || 800
    const H = rect.height || 500

    // Prepare items sorted by volume desc
    const items = _data
      .filter(d => d.volume > 0)
      .sort((a, b) => b.volume - a.volume)
      .map(d => ({ ...d, value: Math.sqrt(d.volume) })) // sqrt so BTC doesn't dominate 80%

    const cells = squarify(items, { x: 0, y: 0, w: W, h: H })

    for (const cell of cells) {
      const div = document.createElement('div')
      div.className = 'tm-cell'
      div.style.cssText = `
        position:absolute;
        left:${cell.x}px; top:${cell.y}px;
        width:${cell.w}px; height:${cell.h}px;
        background:${getColor(cell)};
        border:1px solid rgba(0,0,0,0.3);
        overflow:hidden;
        cursor:pointer;
        display:flex; flex-direction:column;
        align-items:center; justify-content:center;
        transition: filter 0.15s;
      `

      // Label sizing based on cell area
      const area = cell.w * cell.h
      const showFull = area > 4000
      const showMini = area > 1500

      if (showFull) {
        const sign = cell.changePct >= 0 ? '+' : ''
        const rsiStr = cell.rsi !== null ? `RSI ${cell.rsi}` : ''
        div.innerHTML = `
          <span style="font-weight:700;font-size:${Math.min(16, cell.w / 6)}px;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,0.6);line-height:1.2">${cell.symbol}</span>
          <span style="font-size:${Math.min(12, cell.w / 8)}px;color:rgba(255,255,255,0.85);line-height:1.3">${sign}${cell.changePct}%</span>
          ${rsiStr ? `<span style="font-size:${Math.min(10, cell.w / 10)}px;color:rgba(255,255,255,0.6);line-height:1.3">${rsiStr}</span>` : ''}
        `
      } else if (showMini) {
        div.innerHTML = `<span style="font-weight:600;font-size:${Math.min(11, cell.w / 5)}px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,0.6)">${cell.symbol}</span>`
      }

      div.title = `${cell.symbol} | $${cell.price} | ${cell.changePct >= 0 ? '+' : ''}${cell.changePct}% | Vol $${(cell.volume / 1e6).toFixed(0)}M | RSI ${cell.rsi ?? '—'}`

      // Hover
      div.addEventListener('mouseenter', () => { div.style.filter = 'brightness(1.3)' })
      div.addEventListener('mouseleave', () => { div.style.filter = '' })

      // Click → open modal chart
      div.addEventListener('click', () => {
        if (typeof openCoinModal === 'function') {
          openCoinModal(cell.pair)
        }
      })

      wrapper.appendChild(div)
    }
  }

  // ── Data fetch ─────────────────────────────────────────────────
  async function fetchData() {
    if (_loading) return
    _loading = true
    try {
      const resp = await fetch('/api/treemap')
      const json = await resp.json()
      if (json.success && Array.isArray(json.data)) {
        _data = json.data
        render()
      }
    } catch (err) {
      console.warn('[treemap] fetch error:', err.message)
    } finally {
      _loading = false
    }
  }

  // ── Init / Stop ────────────────────────────────────────────────
  function init() {
    _container = document.getElementById('tab-treemap')
    if (!_container) return

    // Build toolbar + wrapper if first init
    if (!_container.querySelector('.tm-wrapper')) {
      _container.innerHTML = `
        <div class="tm-toolbar" style="display:flex;align-items:center;gap:10px;padding:8px 12px;background:rgba(255,255,255,0.03);border-bottom:1px solid rgba(255,255,255,0.06);">
          <span style="font-size:13px;color:var(--text-muted);font-weight:600;">Color by:</span>
          <button class="tm-mode-btn mc-tf-btn active" data-mode="rsi" style="font-size:11px;padding:2px 10px;">RSI</button>
          <button class="tm-mode-btn mc-tf-btn" data-mode="change" style="font-size:11px;padding:2px 10px;">24h %</button>
          <div style="flex:1"></div>
          <span class="tm-legend" style="font-size:10px;color:var(--text-muted);">
            ${_colorMode === 'rsi'
              ? '🟢 Oversold ← RSI → Overbought 🔴'
              : '🔴 Falling ← 24h% → Rising 🟢'}
          </span>
        </div>
        <div class="tm-wrapper" style="position:relative;flex:1;min-height:0;overflow:hidden;"></div>
      `

      // Mode toggle buttons
      _container.querySelectorAll('.tm-mode-btn').forEach(btn => {
        btn.addEventListener('click', () => {
          _colorMode = btn.dataset.mode
          _container.querySelectorAll('.tm-mode-btn').forEach(b => b.classList.remove('active'))
          btn.classList.add('active')
          // Update legend
          const legend = _container.querySelector('.tm-legend')
          if (legend) {
            legend.textContent = _colorMode === 'rsi'
              ? '🟢 Oversold ← RSI → Overbought 🔴'
              : '🔴 Falling ← 24h% → Rising 🟢'
          }
          render()
        })
      })

      // Resize handler
      const ro = new ResizeObserver(() => { if (_data.length) render() })
      ro.observe(_container.querySelector('.tm-wrapper'))
    }

    fetchData()
    if (_refreshTimer) clearInterval(_refreshTimer)
    _refreshTimer = setInterval(fetchData, REFRESH_MS)
  }

  function stop() {
    if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null }
  }

  return { init, stop, render }
})()

📜 Git History

22987cbfeat: RSI/Momentum Treemap tab (Step 9 — market visualization)8 weeks ago
Show last diff
Loading...