← back to Bertha

kalshi-dash/src/pages/Heatmap.jsx

987 lines

import React, { useState, useEffect, useMemo } from 'react';
import { api } from '../api';
import RingGauge from '../components/RingGauge';
import {
  PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer,
  BarChart, Bar, XAxis, YAxis, CartesianGrid,
} from 'recharts';
import { COLOR_CYCLE } from '../utils/chartTheme';

/* ── Chart 4: Category P&L Donut ── */
function CategoryDonutChart({ categories }) {
  const entries = Object.entries(categories || {});
  if (!entries.length) return null;

  const pieData = entries
    .map(([cat, markets], i) => {
      const arr = Array.isArray(markets) ? markets : [];
      const rawPnl = arr.reduce((s, m) => s + (m.pnl || 0), 0);
      return {
        name: cat,
        value: Math.max(Math.abs(rawPnl), 1),
        rawPnl,
        color: COLOR_CYCLE[i % COLOR_CYCLE.length],
      };
    })
    .filter(d => Math.abs(d.rawPnl) > 0)
    .sort((a, b) => b.value - a.value);

  const totalPnl = entries.reduce((s, [, markets]) => {
    const arr = Array.isArray(markets) ? markets : [];
    return s + arr.reduce((ss, m) => ss + (m.pnl || 0), 0);
  }, 0);

  const CustomTooltip = ({ active, payload }) => {
    if (!active || !payload?.length) return null;
    const d = payload[0]?.payload || {};
    return (
      <div style={{
        background: '#1C1C24', border: '1px solid #2A2A35', borderRadius: 8,
        padding: '8px 12px', fontSize: 12, fontFamily: "'Inter', sans-serif",
      }}>
        <p style={{ color: d.color, fontWeight: 700, marginBottom: 4 }}>{d.name}</p>
        <p className="mono" style={{ color: d.rawPnl >= 0 ? '#00C389' : '#FF5C5C', fontWeight: 700 }}>
          {d.rawPnl >= 0 ? '+' : ''}${(d.rawPnl / 100).toFixed(2)}
        </p>
      </div>
    );
  };

  const CustomLegend = () => (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', justifyContent: 'center', marginTop: 8 }}>
      {pieData.map(d => (
        <div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
          <div style={{ width: 8, height: 8, borderRadius: '50%', background: d.color, flexShrink: 0 }} />
          <span style={{ fontSize: 11, color: '#A8A8A8', fontFamily: "'Inter', sans-serif" }}>{d.name}</span>
        </div>
      ))}
    </div>
  );

  return (
    <div className="card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
      <h3 className="section-title" style={{ fontSize: '1rem', marginBottom: 4, alignSelf: 'flex-start' }}>
        P&L by Category
      </h3>
      <div style={{ position: 'relative', width: '100%', height: 220 }}>
        <ResponsiveContainer width="100%" height="100%">
          <PieChart>
            <defs>
              {pieData.map(d => (
                <radialGradient key={d.name} id={`donut-grad-${d.name}`} cx="50%" cy="50%" r="50%">
                  <stop offset="0%" stopColor={d.color} stopOpacity={0.9} />
                  <stop offset="100%" stopColor={d.color} stopOpacity={0.6} />
                </radialGradient>
              ))}
            </defs>
            <Pie
              data={pieData}
              cx="50%"
              cy="50%"
              innerRadius={65}
              outerRadius={95}
              paddingAngle={2}
              dataKey="value"
              startAngle={90}
              endAngle={-270}
            >
              {pieData.map((entry, i) => (
                <Cell key={entry.name} fill={entry.color} fillOpacity={0.85} stroke="transparent" />
              ))}
            </Pie>
            <RechartsTooltip content={<CustomTooltip />} />
          </PieChart>
        </ResponsiveContainer>
        {/* Center total P&L */}
        <div style={{
          position: 'absolute', inset: 0,
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          pointerEvents: 'none',
        }}>
          <span style={{ fontSize: 10, color: '#6B7280', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
            Total P&L
          </span>
          <span className="mono" style={{
            fontSize: 20, fontWeight: 700, lineHeight: 1.2,
            color: totalPnl >= 0 ? '#00C389' : '#FF5C5C',
            textShadow: `0 0 10px ${totalPnl >= 0 ? 'rgba(0,195,137,0.4)' : 'rgba(255,92,92,0.4)'}`,
          }}>
            {totalPnl >= 0 ? '+' : ''}${Math.abs(totalPnl / 100).toFixed(0)}
          </span>
        </div>
      </div>
      <CustomLegend />
    </div>
  );
}

/* ── Chart 5: Category Win/Loss Stacked Bar ── */
function CategoryWinLossChart({ categories }) {
  const entries = Object.entries(categories || {});
  if (!entries.length) return null;

  const barData = entries
    .map(([cat, markets]) => {
      const arr = Array.isArray(markets) ? markets : [];
      const wins = arr.reduce((s, m) => s + (m.wins || 0), 0);
      const losses = arr.reduce((s, m) => s + (m.losses || 0), 0);
      const total = wins + losses;
      const wr = total > 0 ? ((wins / total) * 100).toFixed(1) : 0;
      return { name: cat, wins, losses, total, wr: Number(wr) };
    })
    .filter(d => d.total > 0)
    .sort((a, b) => b.total - a.total);

  const CustomTooltip = ({ active, payload, label }) => {
    if (!active || !payload?.length) return null;
    const d = payload[0]?.payload || {};
    return (
      <div style={{
        background: '#1C1C24', border: '1px solid #2A2A35', borderRadius: 8,
        padding: '10px 14px', fontSize: 12, fontFamily: "'Inter', sans-serif", minWidth: 150,
      }}>
        <p style={{ color: '#E0E0E0', fontWeight: 700, marginBottom: 6 }}>{label}</p>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
            <span style={{ color: '#6B7280' }}>Wins</span>
            <span className="mono" style={{ color: '#00C389', fontWeight: 700 }}>{d.wins}</span>
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
            <span style={{ color: '#6B7280' }}>Losses</span>
            <span className="mono" style={{ color: '#FF5C5C', fontWeight: 700 }}>{d.losses}</span>
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
            <span style={{ color: '#6B7280' }}>Win Rate</span>
            <span className="mono" style={{ color: d.wr >= 50 ? '#00C389' : '#FF5C5C', fontWeight: 700 }}>{d.wr}%</span>
          </div>
        </div>
      </div>
    );
  };

  return (
    <div className="card">
      <h3 className="section-title" style={{ fontSize: '1rem', marginBottom: 16 }}>
        Category Win / Loss
      </h3>
      <ResponsiveContainer width="100%" height={220}>
        <BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
          <CartesianGrid
            strokeDasharray="3 3"
            stroke="#2A2A35"
            strokeOpacity={0.6}
            vertical={false}
          />
          <XAxis
            dataKey="name"
            stroke="#2A2A35"
            tick={{ fill: '#6B7280', fontSize: 10, fontFamily: "'Inter', sans-serif" }}
            tickLine={false}
            axisLine={{ stroke: '#2A2A35' }}
          />
          <YAxis
            stroke="#2A2A35"
            tick={{ fill: '#6B7280', fontSize: 11, fontFamily: "'Inter', sans-serif" }}
            tickLine={false}
            axisLine={false}
            allowDecimals={false}
          />
          <RechartsTooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(0,240,255,0.04)' }} />
          <Bar dataKey="wins" stackId="a" fill="#00C389" fillOpacity={0.85} radius={[0, 0, 0, 0]} maxBarSize={40} name="Wins" />
          <Bar dataKey="losses" stackId="a" fill="#FF5C5C" fillOpacity={0.85} radius={[4, 4, 0, 0]} maxBarSize={40} name="Losses" />
        </BarChart>
      </ResponsiveContainer>
      {/* Legend */}
      <div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginTop: 8 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <div style={{ width: 10, height: 10, borderRadius: 2, background: '#00C389' }} />
          <span style={{ fontSize: 11, color: '#A8A8A8' }}>Wins</span>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <div style={{ width: 10, height: 10, borderRadius: 2, background: '#FF5C5C' }} />
          <span style={{ fontSize: 11, color: '#A8A8A8' }}>Losses</span>
        </div>
      </div>
    </div>
  );
}

// ── Market Category Classification ──
const CATEGORY_MAP = {
  // Politics
  'trump': 'Politics', 'president': 'Politics', 'impeach': 'Politics', 'congress': 'Politics',
  'senate': 'Politics', 'house': 'Politics', 'governor': 'Politics', 'election': 'Politics',
  'democrat': 'Politics', 'republican': 'Politics', 'vote': 'Politics', 'biden': 'Politics',
  'vance': 'Politics', 'cabinet': 'Politics', 'scotus': 'Politics', 'supreme': 'Politics',
  'pardon': 'Politics', 'approval': 'Politics', 'poll': 'Politics',
  'secretary': 'Politics', 'defense': 'Politics', 'attorney': 'Politics',
  // Economics
  'gdp': 'Economy', 'inflation': 'Economy', 'cpi': 'Economy', 'fed': 'Economy',
  'interest rate': 'Economy', 'unemployment': 'Economy', 'jobs': 'Economy', 'payroll': 'Economy',
  'recession': 'Economy', 'stock': 'Economy', 'sp500': 'Economy', 's&p': 'Economy',
  'nasdaq': 'Economy', 'dow': 'Economy', 'treasury': 'Economy', 'bond': 'Economy',
  'debt': 'Economy', 'trade': 'Economy', 'tariff': 'Economy', 'deficit': 'Economy',
  // Weather
  'temperature': 'Weather', 'rain': 'Weather', 'snow': 'Weather', 'hurricane': 'Weather',
  'weather': 'Weather', 'tornado': 'Weather', 'flood': 'Weather', 'heat': 'Weather',
  'cold': 'Weather', 'storm': 'Weather', 'wind': 'Weather', 'precipitation': 'Weather',
  'climate': 'Weather',
  // Crypto
  'bitcoin': 'Crypto', 'btc': 'Crypto', 'ethereum': 'Crypto', 'eth': 'Crypto',
  'crypto': 'Crypto', 'token': 'Crypto', 'blockchain': 'Crypto', 'defi': 'Crypto',
  // Sports
  'nfl': 'Sports', 'nba': 'Sports', 'mlb': 'Sports', 'nhl': 'Sports',
  'soccer': 'Sports', 'tennis': 'Sports', 'super bowl': 'Sports', 'championship': 'Sports',
  'game': 'Sports', 'match': 'Sports', 'playoff': 'Sports', 'world series': 'Sports',
  // Tech
  'ai': 'Tech', 'spacex': 'Tech', 'tesla': 'Tech', 'apple': 'Tech',
  'google': 'Tech', 'meta': 'Tech', 'openai': 'Tech', 'chatgpt': 'Tech',
  'tech': 'Tech', 'rocket': 'Tech', 'launch': 'Tech',
  // Culture
  'oscar': 'Culture', 'movie': 'Culture', 'tv': 'Culture', 'celebrity': 'Culture',
  'grammy': 'Culture', 'award': 'Culture', 'music': 'Culture', 'entertainment': 'Culture',
  // Energy
  'oil': 'Energy', 'gas': 'Energy', 'energy': 'Energy', 'solar': 'Energy',
  'opec': 'Energy', 'crude': 'Energy', 'coal': 'Energy', 'renewable': 'Energy',
};

const CATEGORY_COLORS = {
  'Politics':  { bg: 'var(--blue)',   glow: 'rgba(59,130,246,0.3)',   hex: '#3B82F6' },
  'Economy':   { bg: 'var(--green)',  glow: 'rgba(0,195,137,0.3)',    hex: '#00C389' },
  'Weather':   { bg: 'var(--cyan)',   glow: 'rgba(0,240,255,0.3)',    hex: '#00F0FF' },
  'Crypto':    { bg: 'var(--orange)', glow: 'rgba(247,147,26,0.3)',   hex: '#F7931A' },
  'Sports':    { bg: 'var(--purple)', glow: 'rgba(168,85,247,0.3)',   hex: '#A855F7' },
  'Tech':      { bg: 'var(--cyan)',   glow: 'rgba(0,240,255,0.3)',    hex: '#00F0FF' },
  'Culture':   { bg: 'var(--orange)', glow: 'rgba(247,147,26,0.3)',  hex: '#F7931A' },
  'Energy':    { bg: 'var(--orange)', glow: 'rgba(247,147,26,0.3)',  hex: '#F7931A' },
  'Other':     { bg: 'var(--text-muted)', glow: 'rgba(107,114,128,0.3)', hex: '#6B7280' },
};

function classifyMarket(title) {
  const lower = (title || '').toLowerCase();
  for (const [keyword, category] of Object.entries(CATEGORY_MAP)) {
    if (lower.includes(keyword)) return category;
  }
  return 'Other';
}

function getPnlColor(pnl) {
  if (pnl > 50000) return 'var(--green)';
  if (pnl > 10000) return '#4ade80';
  if (pnl > 1000)  return '#86efac';
  if (pnl > 0)     return '#bbf7d0';
  if (pnl === 0)   return 'var(--text-muted)';
  if (pnl > -1000) return '#fecaca';
  if (pnl > -10000) return '#f87171';
  return 'var(--red)';
}

function getPnlBg(pnl) {
  if (pnl > 50000)  return 'rgba(0,195,137,0.35)';
  if (pnl > 10000)  return 'rgba(0,195,137,0.25)';
  if (pnl > 1000)   return 'rgba(0,195,137,0.15)';
  if (pnl > 0)      return 'rgba(0,195,137,0.08)';
  if (pnl === 0)    return 'rgba(107,114,128,0.15)';
  if (pnl > -1000)  return 'rgba(255,92,92,0.08)';
  if (pnl > -10000) return 'rgba(255,92,92,0.15)';
  return 'rgba(255,92,92,0.30)';
}

// ── Treemap Layout Algorithm ──
function squarify(items, x, y, w, h) {
  if (!items.length) return [];
  if (items.length === 1) {
    return [{ ...items[0], x, y, w, h }];
  }

  const total = items.reduce((s, i) => s + i.size, 0);
  if (total === 0) return items.map(i => ({ ...i, x, y, w: 0, h: 0 }));

  // Split into two groups using golden ratio
  let sum = 0;
  let splitIdx = 0;
  const target = total * 0.5;
  for (let i = 0; i < items.length; i++) {
    sum += items[i].size;
    if (sum >= target) { splitIdx = i + 1; break; }
  }
  if (splitIdx === 0) splitIdx = 1;
  if (splitIdx >= items.length) splitIdx = items.length - 1;

  const left = items.slice(0, splitIdx);
  const right = items.slice(splitIdx);
  const leftSize = left.reduce((s, i) => s + i.size, 0);
  const ratio = leftSize / total;

  if (w >= h) {
    const leftW = w * ratio;
    return [
      ...squarify(left, x, y, leftW, h),
      ...squarify(right, x + leftW, y, w - leftW, h),
    ];
  } else {
    const leftH = h * ratio;
    return [
      ...squarify(left, x, y, w, leftH),
      ...squarify(right, x, y + leftH, w, h - leftH),
    ];
  }
}

// ── Individual Market Tile ──
function MarketTile({ item, onClick, isSelected }) {
  const pnlDollars = (item.pnl / 100).toFixed(2);
  const winRate = item.trades > 0 ? ((item.wins / item.trades) * 100).toFixed(0) : '0';
  const isSmall = item.w < 120 || item.h < 80;
  const isTiny = item.w < 80 || item.h < 50;

  return (
    <div
      onClick={() => onClick(item)}
      className="absolute cursor-pointer overflow-hidden group"
      style={{
        left: item.x,
        top: item.y,
        width: item.w,
        height: item.h,
        backgroundColor: getPnlBg(item.pnl),
        border: isSelected
          ? '2px solid var(--cyan)'
          : '1px solid var(--border)',
        borderRadius: 6,
        zIndex: isSelected ? 10 : 1,
        transition: 'all 150ms ease',
        boxShadow: isSelected ? '0 0 16px rgba(0,240,255,0.4)' : undefined,
      }}
    >
      {/* Hover glow */}
      <div
        className="absolute inset-0 opacity-0 group-hover:opacity-100 pointer-events-none"
        style={{
          background: `radial-gradient(circle at center, ${getPnlBg(item.pnl).replace(')', ', transparent)').replace('rgba', 'radial-gradient').includes('radial') ? getPnlBg(item.pnl) : getPnlBg(item.pnl)}, transparent)`,
          boxShadow: `inset 0 0 12px ${item.pnl >= 0 ? 'rgba(0,195,137,0.3)' : 'rgba(255,92,92,0.3)'}`,
          transition: 'opacity 200ms ease',
        }}
      />

      <div className="relative h-full p-1.5 flex flex-col justify-between z-10">
        {!isTiny && (
          <>
            <div className="truncate text-[10px] font-medium leading-tight" style={{ color: 'var(--text-dim)' }}>
              {item.shortTitle}
            </div>
            <div className="flex items-end justify-between mt-auto">
              <span className="mono font-bold text-xs" style={{ color: getPnlColor(item.pnl) }}>
                {item.pnl >= 0 ? '+' : ''}{pnlDollars > 999 ? `$${(item.pnl / 100 / 1000).toFixed(1)}k` : `$${pnlDollars}`}
              </span>
              {!isSmall && (
                <span className="text-[9px] font-bold mono" style={{
                  color: Number(winRate) >= 80 ? 'var(--green)' : Number(winRate) >= 50 ? 'var(--orange)' : 'var(--red)',
                }}>
                  {winRate}%
                </span>
              )}
            </div>
          </>
        )}
        {isTiny && (
          <div className="flex items-center justify-center h-full">
            <span className="mono font-bold text-[10px]" style={{ color: getPnlColor(item.pnl) }}>
              {item.pnl >= 0 ? '+' : ''}{(item.pnl / 100).toFixed(0)}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}

// ── Category Header Tile ──
function CategoryBlock({ category, items, x, y, w, h, onTileClick, selectedTicker }) {
  const catColor = CATEGORY_COLORS[category] || CATEGORY_COLORS.Other;
  const totalPnl = items.reduce((s, i) => s + i.pnl, 0);
  const totalTrades = items.reduce((s, i) => s + i.trades, 0);
  const headerH = Math.min(28, h * 0.15);
  const innerItems = squarify(items, 0, 0, w, h - headerH);

  return (
    <div
      className="absolute overflow-hidden"
      style={{
        left: x,
        top: y,
        width: w,
        height: h,
        borderRadius: 8,
        border: `1px solid ${catColor.hex}40`,
      }}
    >
      {/* Category Header */}
      <div
        className="flex items-center justify-between px-2 relative z-20"
        style={{
          height: headerH,
          background: `linear-gradient(135deg, ${catColor.hex}25, ${catColor.hex}0D)`,
          borderBottom: `1px solid ${catColor.hex}40`,
        }}
      >
        <span className="text-xs font-bold tracking-wide heading" style={{ color: catColor.hex }}>
          {category}
        </span>
        <span className="text-[10px] mono" style={{ color: totalPnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
          {totalPnl >= 0 ? '+' : ''}${(totalPnl / 100).toFixed(0)} | {totalTrades} trades
        </span>
      </div>

      {/* Market Tiles */}
      <div className="relative" style={{ height: h - headerH }}>
        {innerItems.map(tile => (
          <MarketTile
            key={tile.ticker}
            item={{ ...tile, y: tile.y }}
            onClick={onTileClick}
            isSelected={selectedTicker === tile.ticker}
          />
        ))}
      </div>
    </div>
  );
}

// ── Market Detail Panel ──
function DetailPanel({ market, onClose }) {
  if (!market) return null;
  const pnlDollars = (market.pnl / 100).toFixed(2);
  const winRate = market.trades > 0 ? ((market.wins / market.trades) * 100).toFixed(1) : '0';
  const catColor = CATEGORY_COLORS[market.category] || CATEGORY_COLORS.Other;

  return (
    <div className="card-glow fade-in">
      <div className="flex items-start justify-between mb-4">
        <div className="flex-1 min-w-0 mr-4">
          <h3 className="heading text-sm font-bold truncate" style={{ color: 'var(--text)' }}>{market.title}</h3>
          <p className="mono text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{market.ticker}</p>
        </div>
        <button
          onClick={onClose}
          className="btn btn-ghost text-lg leading-none"
          style={{ padding: '0 6px' }}
        >
          &times;
        </button>
      </div>

      <div className="grid grid-cols-4 gap-3 text-center">
        {[
          {
            label: 'P&L',
            value: `${market.pnl >= 0 ? '+' : ''}$${pnlDollars}`,
            color: market.pnl >= 0 ? 'var(--green)' : 'var(--red)',
          },
          {
            label: 'Win Rate',
            value: `${winRate}%`,
            color: Number(winRate) >= 80 ? 'var(--green)' : Number(winRate) >= 50 ? 'var(--orange)' : 'var(--red)',
          },
          {
            label: 'Trades',
            value: market.trades.toLocaleString(),
            color: 'var(--orange)',
          },
          {
            label: 'Record',
            valueEl: (
              <p className="text-lg font-bold">
                <span style={{ color: 'var(--green)' }}>{market.wins}</span>
                <span style={{ color: 'var(--border-light)' }} className="mx-0.5">-</span>
                <span style={{ color: 'var(--red)' }}>{market.losses}</span>
              </p>
            ),
          },
        ].map(s => (
          <div
            key={s.label}
            className="rounded-lg p-2.5 text-center"
            style={{ background: 'var(--surface-el)', border: '1px solid var(--border)' }}
          >
            <p className="stat-label mb-1">{s.label}</p>
            {s.valueEl || (
              <p className="stat-v text-lg" style={{ color: s.color }}>{s.value}</p>
            )}
          </div>
        ))}
      </div>

      <div className="mt-3 flex items-center justify-between text-xs">
        <span style={{ color: 'var(--text-muted)' }}>
          Category:{' '}
          <span style={{ color: catColor.hex }} className="font-semibold">{market.category}</span>
        </span>
        {market.url && (
          <a
            href={market.url}
            target="_blank"
            rel="noopener noreferrer"
            className="text-cyan transition-colors"
            style={{ color: 'var(--cyan)' }}
          >
            View on Kalshi &rarr;
          </a>
        )}
      </div>

      {market.edge && (
        <div
          className="mt-2 text-[11px] rounded-lg px-3 py-2"
          style={{
            color: 'var(--text-dim)',
            background: 'var(--surface-el)',
            border: '1px solid var(--border)',
          }}
        >
          {market.edge}
        </div>
      )}
    </div>
  );
}

// ── Legend ──
function Legend() {
  const pnlRanges = [
    { label: '> $500',   bg: 'rgba(0,195,137,0.35)' },
    { label: '$100-500', bg: 'rgba(0,195,137,0.25)' },
    { label: '$10-100',  bg: 'rgba(0,195,137,0.15)' },
    { label: '$0-10',    bg: 'rgba(0,195,137,0.08)' },
    { label: '$0',       bg: 'rgba(107,114,128,0.15)' },
    { label: '-$10',     bg: 'rgba(255,92,92,0.08)' },
    { label: '-$100',    bg: 'rgba(255,92,92,0.15)' },
    { label: '< -$100',  bg: 'rgba(255,92,92,0.30)' },
  ];

  return (
    <div className="flex items-center gap-2 flex-wrap">
      <span className="stat-label mr-1">P&L Scale:</span>
      {pnlRanges.map(r => (
        <div key={r.label} className="flex items-center gap-1">
          <div
            className="w-3 h-3 rounded-sm"
            style={{ backgroundColor: r.bg, border: '1px solid var(--border)' }}
          />
          <span className="text-[9px]" style={{ color: 'var(--text-muted)' }}>{r.label}</span>
        </div>
      ))}
    </div>
  );
}

// ── Main Heatmap Page ──
export default function Heatmap() {
  const [data, setData] = useState(null);
  const [signals, setSignals] = useState(null);
  const [loading, setLoading] = useState(true);
  const [selectedTicker, setSelectedTicker] = useState(null);
  const [viewMode, setViewMode] = useState('category'); // 'category' | 'flat'
  const [sortBy, setSortBy] = useState('pnl'); // 'pnl' | 'trades' | 'winrate'

  useEffect(() => {
    load();
    const i = setInterval(load, 30000);
    return () => clearInterval(i);
  }, []);

  async function load() {
    try {
      const [d, s] = await Promise.all([api('/dashboard'), api('/signals')]);
      setData(d);
      setSignals(s);
    } catch {}
    setLoading(false);
  }

  // Combine top_markets and worst_markets, strategies into categories
  const { categories, allMarkets, summary } = useMemo(() => {
    if (!data) return { categories: {}, allMarkets: [], summary: {} };

    const markets = [];
    const seen = new Set();

    // Add top_markets
    for (const m of (data.top_markets || [])) {
      if (!seen.has(m.ticker)) {
        seen.add(m.ticker);
        const cat = classifyMarket(m.title);
        markets.push({
          ...m,
          category: cat,
          trades: (m.wins || 0) + (m.losses || 0),
          shortTitle: (m.title || m.ticker).split('?')[0].substring(0, 40),
          size: Math.max(Math.abs(m.pnl || 1), 100),
        });
      }
    }

    // Add worst_markets
    for (const m of (data.worst_markets || [])) {
      if (!seen.has(m.ticker)) {
        seen.add(m.ticker);
        const cat = classifyMarket(m.title);
        markets.push({
          ...m,
          category: cat,
          trades: (m.wins || 0) + (m.losses || 0),
          shortTitle: (m.title || m.ticker).split('?')[0].substring(0, 40),
          size: Math.max(Math.abs(m.pnl || 1), 100),
        });
      }
    }

    // Add strategies as synthetic market entries
    for (const s of (data.strategies || [])) {
      const key = `strategy-${s.strategy || s.name}`;
      if (!seen.has(key)) {
        seen.add(key);
        markets.push({
          ticker: key,
          title: `Strategy: ${s.name}`,
          shortTitle: s.name,
          category: 'Other',
          wins: s.wins || 0,
          losses: s.losses || 0,
          trades: (s.wins || 0) + (s.losses || 0),
          pnl: s.pnl || 0,
          edge: `Win Rate: ${s.win_rate || 0}%`,
          size: Math.max(Math.abs(s.pnl || 1), 100),
        });
      }
    }

    // Sort
    if (sortBy === 'pnl') markets.sort((a, b) => b.pnl - a.pnl);
    else if (sortBy === 'trades') markets.sort((a, b) => b.trades - a.trades);
    else if (sortBy === 'winrate') {
      markets.sort((a, b) => {
        const wrA = a.trades > 0 ? a.wins / a.trades : 0;
        const wrB = b.trades > 0 ? b.wins / b.trades : 0;
        return wrB - wrA;
      });
    }

    // Group by category
    const cats = {};
    for (const m of markets) {
      if (!cats[m.category]) cats[m.category] = [];
      cats[m.category].push(m);
    }

    // Summary stats
    const totalPnl = markets.reduce((s, m) => s + m.pnl, 0);
    const totalTrades = markets.reduce((s, m) => s + m.trades, 0);
    const totalWins = markets.reduce((s, m) => s + m.wins, 0);
    const totalLosses = markets.reduce((s, m) => s + m.losses, 0);
    const profitableCount = markets.filter(m => m.pnl > 0).length;

    return {
      categories: cats,
      allMarkets: markets,
      summary: { totalPnl, totalTrades, totalWins, totalLosses, profitableCount, totalMarkets: markets.length },
    };
  }, [data, sortBy]);

  const selectedMarket = allMarkets.find(m => m.ticker === selectedTicker);

  if (loading) return (
    <div className="flex items-center justify-center h-64">
      <div
        className="w-8 h-8 rounded-full border-2 border-t-transparent animate-spin"
        style={{ borderColor: 'var(--cyan)', borderTopColor: 'transparent' }}
      />
    </div>
  );

  // Calculate treemap layout
  const WIDTH = 1200;
  const HEIGHT = 700;

  const categoryEntries = Object.entries(categories).sort((a, b) => {
    const pnlA = a[1].reduce((s, m) => s + Math.abs(m.pnl), 0);
    const pnlB = b[1].reduce((s, m) => s + Math.abs(m.pnl), 0);
    return pnlB - pnlA;
  });

  const categoryBlocks = squarify(
    categoryEntries.map(([cat, items]) => ({
      name: cat,
      size: items.reduce((s, i) => s + Math.abs(i.pnl || 1), 0) + items.length * 100,
      items,
    })),
    0, 0, WIDTH, HEIGHT
  );

  // Flat mode layout
  const flatBlocks = viewMode === 'flat' ? squarify(
    allMarkets.map(m => ({ ...m, size: Math.max(Math.abs(m.pnl || 1), 200) })),
    0, 0, WIDTH, HEIGHT
  ) : [];

  const summaryStats = [
    {
      label: 'Total P&L',
      value: `${summary.totalPnl >= 0 ? '+' : ''}$${(summary.totalPnl / 100).toLocaleString(undefined, { minimumFractionDigits: 0 })}`,
      color: summary.totalPnl >= 0 ? 'var(--green)' : 'var(--red)',
    },
    { label: 'Total Trades', value: summary.totalTrades?.toLocaleString() || '0', color: 'var(--orange)' },
    {
      label: 'Win Rate',
      value: summary.totalTrades > 0 ? `${((summary.totalWins / summary.totalTrades) * 100).toFixed(1)}%` : '0%',
      color: 'var(--blue)',
    },
    { label: 'Markets', value: summary.totalMarkets || 0, color: 'var(--purple)' },
    { label: 'Profitable', value: summary.profitableCount || 0, color: 'var(--green)' },
    { label: 'Categories', value: Object.keys(categories).length, color: 'var(--cyan)' },
  ];

  return (
    <div className="space-y-6">
      {/* Page Header */}
      <div className="flex items-center justify-between">
        <div>
          <h1 className="heading text-2xl font-bold kalshi-gradient">Market Heatmap</h1>
          <p className="section-subtitle mt-1">Treemap visualization of trading activity and P&L by market category</p>
        </div>
        <div className="flex items-center gap-3">
          {/* View mode toggle */}
          <div
            className="flex items-center gap-0.5 rounded-lg p-0.5"
            style={{ background: 'var(--surface-el)', border: '1px solid var(--border)' }}
          >
            {['category', 'flat'].map(mode => (
              <button
                key={mode}
                onClick={() => setViewMode(mode)}
                className="btn btn-sm"
                style={viewMode === mode
                  ? { background: 'var(--cyan)', color: 'var(--bg)', fontWeight: 700 }
                  : { background: 'transparent', color: 'var(--text-muted)' }
                }
              >
                {mode === 'category' ? 'By Category' : 'All Markets'}
              </button>
            ))}
          </div>

          {/* Sort select */}
          <select
            value={sortBy}
            onChange={e => setSortBy(e.target.value)}
            className="input"
            style={{ width: 'auto', paddingRight: '1.5rem' }}
          >
            <option value="pnl">Sort by P&L</option>
            <option value="trades">Sort by Trades</option>
            <option value="winrate">Sort by Win Rate</option>
          </select>

          <button onClick={load} className="btn btn-o btn-sm">Refresh</button>
        </div>
      </div>

      {/* Summary Stats — Win Rate and Profitable use RingGauge */}
      <div className="grid grid-cols-6 gap-3">
        {summaryStats.map((s, i) => {
          const accentMap = ['card-accent-green', 'card-accent-orange', 'card-accent-blue', 'card-accent-purple', 'card-accent-green', 'card-accent-cyan'];

          // Win Rate (index 2) gets a green ring gauge
          if (s.label === 'Win Rate') {
            const numericWR = parseFloat(s.value) || 0;
            return (
              <div key={s.label} className={`card ${accentMap[i] || 'card-accent-cyan'}`} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
                <RingGauge
                  value={numericWR}
                  size={52}
                  strokeWidth={4}
                  color={numericWR >= 80 ? 'var(--green)' : numericWR >= 50 ? 'var(--orange)' : 'var(--red)'}
                />
                <div>
                  <p className="stat-label mb-0.5">{s.label}</p>
                  <p className="stat-v mono text-lg" style={{ color: s.color }}>{s.value}</p>
                </div>
              </div>
            );
          }

          // Profitable (index 4) gets a cyan ring gauge (profitable% out of total markets)
          if (s.label === 'Profitable') {
            const totalMkts = summaryStats.find(x => x.label === 'Markets')?.value || 1;
            const profitPct = totalMkts > 0 ? Math.round((Number(s.value) / Number(totalMkts)) * 100) : 0;
            return (
              <div key={s.label} className={`card ${accentMap[i] || 'card-accent-cyan'}`} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
                <RingGauge
                  value={profitPct}
                  size={52}
                  strokeWidth={4}
                  color="var(--green)"
                />
                <div>
                  <p className="stat-label mb-0.5">{s.label}</p>
                  <p className="stat-v mono text-lg" style={{ color: s.color }}>{s.value}</p>
                </div>
              </div>
            );
          }

          return (
            <div key={s.label} className={`card ${accentMap[i] || 'card-accent-cyan'}`}>
              <p className="stat-label mb-1">{s.label}</p>
              <p className="stat-v mono text-xl" style={{ color: s.color }}>{s.value}</p>
            </div>
          );
        })}
      </div>

      {/* ── Charts 4 & 5: Category Donut + Win/Loss Bar ── */}
      {Object.keys(categories).length > 0 && (
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 16 }}>
          <CategoryDonutChart categories={categories} />
          <CategoryWinLossChart categories={categories} />
        </div>
      )}

      {/* Category Pills */}
      <div className="flex items-center gap-2 flex-wrap">
        {Object.entries(categories).map(([cat, items]) => {
          const catColor = CATEGORY_COLORS[cat] || CATEGORY_COLORS.Other;
          const catPnl = items.reduce((s, m) => s + m.pnl, 0);
          return (
            <div
              key={cat}
              className="flex items-center gap-1.5 px-3 py-1.5 rounded-full"
              style={{
                backgroundColor: `${catColor.hex}18`,
                border: `1px solid ${catColor.hex}40`,
              }}
            >
              <div className="w-2 h-2 rounded-full" style={{ backgroundColor: catColor.hex }} />
              <span className="text-xs font-semibold heading" style={{ color: catColor.hex }}>{cat}</span>
              <span className="text-[10px]" style={{ color: 'var(--text-muted)' }}>{items.length}</span>
              <span
                className="text-[10px] mono font-bold"
                style={{ color: catPnl >= 0 ? 'var(--green)' : 'var(--red)' }}
              >
                {catPnl >= 0 ? '+' : ''}${(catPnl / 100).toFixed(0)}
              </span>
            </div>
          );
        })}
      </div>

      {/* Treemap */}
      <div
        className="card p-2 relative overflow-hidden"
        style={{ minHeight: HEIGHT + 20 }}
      >
        <div className="relative" style={{ width: WIDTH, height: HEIGHT, margin: '0 auto', maxWidth: '100%' }}>
          {viewMode === 'category' ? (
            categoryBlocks.map(block => (
              <CategoryBlock
                key={block.name}
                category={block.name}
                items={block.items}
                x={block.x}
                y={block.y}
                w={block.w}
                h={block.h}
                onTileClick={m => setSelectedTicker(m.ticker === selectedTicker ? null : m.ticker)}
                selectedTicker={selectedTicker}
              />
            ))
          ) : (
            flatBlocks.map(tile => (
              <MarketTile
                key={tile.ticker}
                item={tile}
                onClick={m => setSelectedTicker(m.ticker === selectedTicker ? null : m.ticker)}
                isSelected={selectedTicker === tile.ticker}
              />
            ))
          )}
        </div>

        {/* Legend overlay */}
        <div className="mt-3 px-2">
          <Legend />
        </div>
      </div>

      {/* Selected Market Detail */}
      {selectedMarket && (
        <DetailPanel market={selectedMarket} onClose={() => setSelectedTicker(null)} />
      )}

      {/* Category Breakdown Table */}
      <div className="card">
        <div className="flex items-center gap-2 mb-4">
          <h2 className="section-title">Category Breakdown</h2>
        </div>
        <table>
          <thead>
            <tr>
              <th>Category</th>
              <th>Markets</th>
              <th>Total Trades</th>
              <th>Win Rate</th>
              <th>P&L</th>
              <th>Best Market</th>
            </tr>
          </thead>
          <tbody>
            {categoryEntries.map(([cat, items]) => {
              const catColor = CATEGORY_COLORS[cat] || CATEGORY_COLORS.Other;
              const catPnl = items.reduce((s, m) => s + m.pnl, 0);
              const catTrades = items.reduce((s, m) => s + m.trades, 0);
              const catWins = items.reduce((s, m) => s + m.wins, 0);
              const catWr = catTrades > 0 ? ((catWins / catTrades) * 100).toFixed(1) : '0';
              const best = items.reduce((b, m) => m.pnl > (b?.pnl || -Infinity) ? m : b, items[0]);

              return (
                <tr key={cat}>
                  <td>
                    <div className="flex items-center gap-2">
                      <div className="w-3 h-3 rounded-full" style={{ backgroundColor: catColor.hex }} />
                      <span className="font-semibold heading" style={{ color: catColor.hex }}>{cat}</span>
                    </div>
                  </td>
                  <td style={{ color: 'var(--text-dim)' }}>{items.length}</td>
                  <td className="mono" style={{ color: 'var(--text-dim)' }}>{catTrades.toLocaleString()}</td>
                  <td
                    className="mono font-bold"
                    style={{
                      color: Number(catWr) >= 80 ? 'var(--green)'
                           : Number(catWr) >= 50 ? 'var(--orange)'
                           : 'var(--red)',
                    }}
                  >
                    {catWr}%
                  </td>
                  <td
                    className="mono font-bold"
                    style={{ color: catPnl >= 0 ? 'var(--green)' : 'var(--red)' }}
                  >
                    {catPnl >= 0 ? '+' : ''}${(catPnl / 100).toFixed(2)}
                  </td>
                  <td
                    className="text-xs truncate max-w-[200px]"
                    style={{ color: 'var(--text-muted)' }}
                  >
                    {best?.shortTitle || '--'}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}