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...