import { useRef, useEffect, useState } from 'react'; const COLORS = { line: '#58a6ff', green: '#3fb950', greenFill: 'rgba(63, 185, 80, 0.12)', red: '#f85149', redFill: 'rgba(248, 81, 73, 0.12)', grid: '#21262d', text: '#8b949e', textMuted: '#484f58', baseline: '#30363d', buy: '#f0c000', sell: '#db6d28', bg: '#0d1117', }; const PADDING = { top: 20, right: 80, bottom: 40, left: 20 }; export function Chart({ prices, trades }) { const canvasRef = useRef(null); const containerRef = useRef(null); const [tooltip, setTooltip] = useState(null); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); useEffect(() => { const updateDimensions = () => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setDimensions({ width: rect.width, height: rect.height }); } }; updateDimensions(); window.addEventListener('resize', updateDimensions); return () => window.removeEventListener('resize', updateDimensions); }, []); useEffect(() => { const canvas = canvasRef.current; if (!canvas || dimensions.width === 0) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; canvas.width = dimensions.width * dpr; canvas.height = dimensions.height * dpr; ctx.scale(dpr, dpr); const width = dimensions.width; const height = dimensions.height; const chartWidth = width - PADDING.left - PADDING.right; const chartHeight = height - PADDING.top - PADDING.bottom; ctx.clearRect(0, 0, width, height); if (!prices || prices.length === 0) { ctx.fillStyle = COLORS.textMuted; ctx.font = '18px Ac437_Rainbow100_re_132, monospace'; ctx.textAlign = 'center'; ctx.fillText('No data for this day', width / 2, height / 2); return; } const priceValues = prices.map(d => d.p || d.price); const times = prices.map(d => d.t || d.timestamp); let minPrice = Math.min(...priceValues); let maxPrice = Math.max(...priceValues); const minTime = Math.min(...times); const maxTime = Math.max(...times); const pricePadding = (maxPrice - minPrice) * 0.05 || 10; minPrice -= pricePadding; maxPrice += pricePadding; const baselinePrice = priceValues[0]; const priceToY = (price) => { const range = maxPrice - minPrice; const normalized = (price - minPrice) / range; return PADDING.top + chartHeight * (1 - normalized); }; const timeToX = (timestamp) => { const range = maxTime - minTime; const normalized = (timestamp - minTime) / range; return PADDING.left + chartWidth * normalized; }; ctx.strokeStyle = COLORS.grid; ctx.lineWidth = 1; const priceStep = calculateNiceStep(maxPrice - minPrice, 5); const startPrice = Math.ceil(minPrice / priceStep) * priceStep; for (let price = startPrice; price <= maxPrice; price += priceStep) { const y = priceToY(price); ctx.beginPath(); ctx.moveTo(PADDING.left, y); ctx.lineTo(width - PADDING.right, y); ctx.stroke(); } const baselineY = priceToY(baselinePrice); ctx.strokeStyle = COLORS.baseline; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(PADDING.left, baselineY); ctx.lineTo(width - PADDING.right, baselineY); ctx.stroke(); ctx.setLineDash([]); if (prices.length >= 2) { ctx.beginPath(); ctx.moveTo(timeToX(times[0]), baselineY); ctx.lineTo(timeToX(times[0]), priceToY(priceValues[0])); for (let i = 1; i < prices.length; i++) { ctx.lineTo(timeToX(times[i]), priceToY(priceValues[i])); } ctx.lineTo(timeToX(times[times.length - 1]), baselineY); ctx.closePath(); const gradient = ctx.createLinearGradient(0, PADDING.top, 0, height - PADDING.bottom); gradient.addColorStop(0, COLORS.greenFill); gradient.addColorStop(0.5, 'rgba(0,0,0,0)'); gradient.addColorStop(1, COLORS.redFill); ctx.fillStyle = gradient; ctx.fill(); } if (prices.length >= 2) { ctx.beginPath(); ctx.strokeStyle = COLORS.line; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.moveTo(timeToX(times[0]), priceToY(priceValues[0])); for (let i = 1; i < prices.length; i++) { ctx.lineTo(timeToX(times[i]), priceToY(priceValues[i])); } ctx.stroke(); } ctx.fillStyle = COLORS.text; ctx.font = '14px Ac437_Rainbow100_re_132, monospace'; ctx.textAlign = 'left'; for (let price = startPrice; price <= maxPrice; price += priceStep) { const y = priceToY(price); ctx.fillText(formatPrice(price), width - PADDING.right + 8, y + 4); } ctx.textAlign = 'center'; const hourMs = 60 * 60 * 1000; const rangeMs = maxTime - minTime; let stepMs = hourMs * 4; if (rangeMs < hourMs * 6) stepMs = hourMs; if (rangeMs < hourMs * 2) stepMs = hourMs / 2; let current = Math.ceil(minTime / stepMs) * stepMs; while (current <= maxTime) { const x = timeToX(current); if (x >= PADDING.left && x <= width - PADDING.right) { const date = new Date(current); const label = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); ctx.fillText(label, x, height - PADDING.bottom + 20); } current += stepMs; } for (const trade of (trades || [])) { const x = timeToX(trade.t || trade.timestamp); const y = priceToY(trade.price); if (x < PADDING.left || x > width - PADDING.right) continue; const color = trade.side === 'buy' ? COLORS.buy : COLORS.sell; const radius = 7; ctx.beginPath(); ctx.arc(x, y, radius + 4, 0, Math.PI * 2); ctx.fillStyle = color + '33'; ctx.fill(); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.beginPath(); ctx.arc(x, y, radius - 3, 0, Math.PI * 2); ctx.fillStyle = COLORS.bg; ctx.fill(); } if (prices.length > 0) { const lastPrice = priceValues[priceValues.length - 1]; const y = priceToY(lastPrice); const x = width - PADDING.right; const isUp = lastPrice >= baselinePrice; const color = isUp ? COLORS.green : COLORS.red; ctx.fillStyle = color; roundRect(ctx, x + 4, y - 11, 70, 22, 4); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold 14px Ac437_Rainbow100_re_132, monospace'; ctx.textAlign = 'left'; ctx.fillText(formatPrice(lastPrice), x + 10, y + 4); } }, [prices, trades, dimensions]); const handleMouseMove = (e) => { if (!prices || prices.length === 0) { setTooltip(null); return; } const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const width = dimensions.width; const height = dimensions.height; const chartWidth = width - PADDING.left - PADDING.right; if (x < PADDING.left || x > width - PADDING.right || y < PADDING.top || y > height - PADDING.bottom) { setTooltip(null); return; } const times = prices.map(d => d.t || d.timestamp); const minTime = Math.min(...times); const maxTime = Math.max(...times); const normalized = (x - PADDING.left) / chartWidth; const timestamp = minTime + normalized * (maxTime - minTime); let nearestPoint = null; let nearestDistance = Infinity; for (const point of prices) { const t = point.t || point.timestamp; const distance = Math.abs(t - timestamp); if (distance < nearestDistance) { nearestDistance = distance; nearestPoint = point; } } if (nearestPoint) { setTooltip({ x: e.clientX - rect.left + 15, y: e.clientY - rect.top - 10, price: nearestPoint.p || nearestPoint.price, timestamp: nearestPoint.t || nearestPoint.timestamp, }); } }; return (
setTooltip(null)} /> {tooltip && (
${tooltip.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{new Date(tooltip.timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
)}
); } function calculateNiceStep(range, targetSteps) { const roughStep = range / targetSteps; const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const normalized = roughStep / magnitude; let niceStep; if (normalized <= 1.5) niceStep = 1; else if (normalized <= 3) niceStep = 2; else if (normalized <= 7) niceStep = 5; else niceStep = 10; return niceStep * magnitude; } function formatPrice(price) { if (price >= 10000) { return '$' + (price / 1000).toFixed(1) + 'k'; } return '$' + price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); } function roundRect(ctx, x, y, width, height, radius) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); }