โ† Back
โ˜†
import { useState, useEffect, useRef, useCallback } from 'react';
import { createChart, AreaSeries, createSeriesMarkers, type ISeriesMarkersPluginApi, type SeriesMarker, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
import { whaleCodename } from '../../utils/whales';
import { formatPrice } from '../../utils/format';
import { useT } from '../../i18n/LanguageContext';

interface PricePoint {
  t: number;
  p: number;
}

interface WhaleEntry {
  address: string;
  side: string;
  price: number;
  timestamp: string;
  size: number;
}

interface Props {
  marketId: string;
}

const INTERVALS = [
  { key: '1d', label: '1D' },
  { key: '1w', label: '1W' },
  { key: '1m', label: '1M' },
  { key: '3m', label: '3M' },
  { key: 'all', label: 'All' },
];

function getThemeColors() {
  const s = getComputedStyle(document.documentElement);
  const get = (v: string) => s.getPropertyValue(v).trim();
  return {
    text: get('--text-secondary') || '#8b949e',
    grid: get('--hover-overlay') || 'rgba(255,255,255,0.04)',
    accent: get('--accent') || '#3b82f6',
    bgCard: get('--bg-card') || '#1a1f2e',
  };
}

export default function PriceChart({ marketId }: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const chartRef = useRef<IChartApi | null>(null);
  const seriesRef = useRef<ISeriesApi<'Area'> | null>(null);
  const markersRef = useRef<ISeriesMarkersPluginApi<Time> | null>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const { lang } = useT();
  const [interval, setInterval] = useState('1w');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [lastPrice, setLastPrice] = useState<number | null>(null);
  // Bumped on theme change to force the data-fetch effect to re-run after the
  // chart is rebuilt (rebuilding creates a fresh, empty series).
  const [themeVersion, setThemeVersion] = useState(0);

  // Rebuild chart on theme change
  const buildChart = useCallback(() => {
    if (!containerRef.current) return;

    // Cleanup old chart
    if (chartRef.current) {
      chartRef.current.remove();
      chartRef.current = null;
      seriesRef.current = null;
      markersRef.current = null;
    }

    const c = getThemeColors();

    const chart = createChart(containerRef.current, {
      width: containerRef.current.clientWidth,
      height: window.innerWidth >= 768 ? 280 : 200,
      layout: {
        background: { type: ColorType.Solid, color: 'transparent' },
        textColor: c.text,
        fontSize: 11,
      },
      grid: {
        vertLines: { color: c.grid },
        horzLines: { color: c.grid },
      },
      rightPriceScale: {
        borderVisible: false,
      },
      timeScale: {
        borderVisible: false,
        timeVisible: true,
      },
      crosshair: {
        horzLine: { color: `${c.accent}4d`, style: 2 },
        vertLine: { color: `${c.accent}4d`, style: 2 },
      },
      handleScale: true,
      handleScroll: true,
    });

    const series = chart.addSeries(AreaSeries, {
      lineColor: c.accent,
      topColor: `${c.accent}4d`,
      bottomColor: `${c.accent}05`,
      lineWidth: 2,
      priceFormat: {
        type: 'custom',
        formatter: (price: number) => `${Math.round(price * 100)}ยข`,
      },
      crosshairMarkerRadius: 5,
      crosshairMarkerBorderWidth: 2,
      crosshairMarkerBorderColor: c.accent,
      crosshairMarkerBackgroundColor: c.bgCard,
    });

    // Crosshair tooltip
    chart.subscribeCrosshairMove(param => {
      const tip = tooltipRef.current;
      if (!tip) return;
      if (!param.time || !param.point || param.point.x < 0 || param.point.y < 0) {
        tip.style.display = 'none';
        return;
      }
      const data = param.seriesData.get(series);
      if (!data || !('value' in data)) {
        tip.style.display = 'none';
        return;
      }
      const price = (data as { value: number }).value;
      const pct = Math.round(price * 100);

      // Format time
      const d = new Date((param.time as number) * 1000);
      const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
      const timeStr = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });

      tip.innerHTML = `<strong>${pct}ยข</strong><span>${dateStr} ${timeStr}</span>`;
      tip.style.display = 'flex';

      // Position tooltip
      const cw = containerRef.current?.clientWidth || 300;
      let left = param.point.x + 12;
      if (left + 120 > cw) left = param.point.x - 130;
      tip.style.left = `${left}px`;
      tip.style.top = `${param.point.y - 10}px`;
    });

    chartRef.current = chart;
    seriesRef.current = series;

    return chart;
  }, []);

  // Create chart on mount
  useEffect(() => {
    buildChart();

    const ro = new ResizeObserver(entries => {
      const { width } = entries[0].contentRect;
      chartRef.current?.applyOptions({ width });
    });
    if (containerRef.current) ro.observe(containerRef.current);

    // Listen for theme changes
    const observer = new MutationObserver(() => {
      buildChart();
      // buildChart() made a fresh empty series โ€” bump themeVersion so the
      // data-fetch effect re-runs and repopulates it (setInterval(prev => prev)
      // was a no-op: same value โ†’ React bails, chart stayed blank).
      setThemeVersion(v => v + 1);
    });
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

    return () => {
      ro.disconnect();
      observer.disconnect();
      if (chartRef.current) {
        chartRef.current.remove();
        chartRef.current = null;
        seriesRef.current = null;
        markersRef.current = null;
      }
    };
  }, [buildChart]);

  // Fetch data when interval or marketId changes
  useEffect(() => {
    const t = setTimeout(() => {
      setLoading(true);
      setError(null);
      if (seriesRef.current) seriesRef.current.setData([]);

      fetch(`/api/screener/market/${encodeURIComponent(marketId)}/history?interval=${interval}`)
        .then(r => r.json())
        .then(d => {
          if (!d.success) { setError(d.error || 'No data'); return; }
          const history: PricePoint[] = d.data.history || [];
          if (history.length === 0) { setError('No price history'); return; }

          const sorted = [...history].sort((a, b) => a.t - b.t);
          const data = sorted.map(pt => ({ time: pt.t as Time, value: pt.p }));

          if (seriesRef.current) {
            seriesRef.current.setData(data);

            // Current price line
            const last = sorted[sorted.length - 1];
            if (last) {
              setLastPrice(last.p);
              seriesRef.current.createPriceLine({
                price: last.p,
                color: getThemeColors().accent,
                lineWidth: 1,
                lineStyle: 2,
                axisLabelVisible: true,
                title: '',
              });
            }
          }
          chartRef.current?.timeScale().fitContent();

          // Overlay followed-whale entry/exit markers (๐Ÿ‹ codename @ price).
          fetch(`/api/screener/market/${encodeURIComponent(marketId)}/whale-entries`)
            .then(r => r.json())
            .then(wd => {
              if (!wd.success || !seriesRef.current) return;
              const markers: SeriesMarker<Time>[] = (wd.data?.entries as WhaleEntry[] || [])
                .map(e => {
                  const iso = e.timestamp.endsWith('Z') ? e.timestamp : e.timestamp + 'Z';
                  const sec = Math.floor(Date.parse(iso) / 1000);
                  if (!Number.isFinite(sec)) return null;
                  const isBuy = (e.side || '').toUpperCase() === 'BUY';
                  return {
                    time: sec as Time,
                    position: isBuy ? 'belowBar' : 'aboveBar',
                    color: isBuy ? '#22c55e' : '#ef4444',
                    shape: isBuy ? 'arrowUp' : 'arrowDown',
                    text: `๐Ÿ‹ ${whaleCodename(e.address, lang)} ${formatPrice(e.price)}`,
                  } as SeriesMarker<Time>;
                })
                .filter((m): m is SeriesMarker<Time> => m !== null)
                .sort((a, b) => (a.time as number) - (b.time as number));
              if (markersRef.current) markersRef.current.setMarkers(markers);
              else markersRef.current = createSeriesMarkers(seriesRef.current, markers);
            })
            .catch(() => { /* markers are best-effort */ });
        })
        .catch(() => setError('Network error'))
        .finally(() => setLoading(false));
    }, 0);
    return () => { clearTimeout(t); };
  }, [marketId, interval, themeVersion, lang]);

  return (
    <div className="price-chart">
      <div className="chart-header">
        <div className="chart-title-row">
          <h4 className="chart-title">Price History <span className="chart-subtitle">YES</span></h4>
          {lastPrice !== null && (
            <span className="chart-current-price">{Math.round(lastPrice * 100)}ยข</span>
          )}
        </div>
        <div className="chart-intervals">
          {INTERVALS.map(iv => (
            <button
              key={iv.key}
              className={`chart-interval ${interval === iv.key ? 'chart-interval-active' : ''}`}
              onClick={() => setInterval(iv.key)}
            >
              {iv.label}
            </button>
          ))}
        </div>
      </div>
      <div className="chart-container" ref={containerRef}>
        <div className="chart-tooltip" ref={tooltipRef} />
        {loading && <div className="chart-overlay">Loading...</div>}
        {error && !loading && <div className="chart-overlay">{error}</div>}
      </div>
    </div>
  );
}

๐Ÿ“œ Git History

6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...