| import { useEffect, useRef, useState, useCallback } from 'react';
|
|
|
| export default function CandlestickChart({ data, tick }) {
|
| const canvasRef = useRef(null);
|
| const containerRef = useRef(null);
|
|
|
|
|
|
|
| const [viewport, setViewport] = useState({ offset: 0, scale: 10 });
|
| const [isDragging, setIsDragging] = useState(false);
|
| const lastMouseX = useRef(0);
|
|
|
| const colors = {
|
| bg: '#080b14',
|
| grid: '#1e2329',
|
| text: '#8b949e',
|
| up: '#26a641',
|
| down: '#da3633',
|
| crosshair: '#f0b429',
|
| };
|
|
|
|
|
| useEffect(() => {
|
| const handleResize = () => draw();
|
| window.addEventListener('resize', handleResize);
|
| return () => window.removeEventListener('resize', handleResize);
|
| }, []);
|
|
|
|
|
| const draw = useCallback(() => {
|
| const canvas = canvasRef.current;
|
| const container = containerRef.current;
|
| if (!canvas || !container || !data) return;
|
|
|
| const rect = container.getBoundingClientRect();
|
| const width = rect.width;
|
| const height = rect.height;
|
|
|
|
|
| const dpr = window.devicePixelRatio || 1;
|
| canvas.width = width * dpr;
|
| canvas.height = height * dpr;
|
| canvas.style.width = `${width}px`;
|
| canvas.style.height = `${height}px`;
|
|
|
| const ctx = canvas.getContext('2d');
|
| ctx.scale(dpr, dpr);
|
|
|
|
|
| ctx.fillStyle = colors.bg;
|
| ctx.fillRect(0, 0, width, height);
|
|
|
| if (data.length === 0) return;
|
|
|
| const { offset, scale } = viewport;
|
| const candleWidth = scale * 0.7;
|
| const rightMargin = 80;
|
| const chartWidth = width - rightMargin;
|
|
|
|
|
|
|
| const visibleCount = Math.ceil(chartWidth / scale);
|
|
|
|
|
|
|
| const rightIndex = data.length - 1 - Math.floor(offset / scale);
|
| const leftIndex = Math.max(0, rightIndex - visibleCount - 1);
|
|
|
|
|
| const visibleData = data.slice(leftIndex, rightIndex + 1);
|
|
|
| if (visibleData.length === 0) return;
|
|
|
|
|
| let minPrice = Infinity;
|
| let maxPrice = -Infinity;
|
| visibleData.forEach(c => {
|
| if (c.low < minPrice) minPrice = c.low;
|
| if (c.high > maxPrice) maxPrice = c.high;
|
| });
|
|
|
|
|
| const padding = (maxPrice - minPrice) * 0.1 || 1.0;
|
| minPrice -= padding;
|
| maxPrice += padding;
|
| const priceRange = maxPrice - minPrice;
|
|
|
|
|
| const getY = (price) => height - ((price - minPrice) / priceRange) * height;
|
|
|
|
|
|
|
|
|
|
|
| const getX = (index) => {
|
| const posFromRight = (data.length - 1 - index) * scale + (offset % scale);
|
| return chartWidth - posFromRight - (scale / 2);
|
| };
|
|
|
|
|
| ctx.strokeStyle = colors.grid;
|
| ctx.lineWidth = 0.5;
|
| ctx.fillStyle = colors.text;
|
| ctx.font = '11px monospace';
|
| ctx.textAlign = 'left';
|
|
|
|
|
| const gridLines = 8;
|
| for (let i = 0; i <= gridLines; i++) {
|
| const y = (height / gridLines) * i;
|
| const price = maxPrice - (i / gridLines) * priceRange;
|
|
|
| ctx.beginPath();
|
| ctx.moveTo(0, y);
|
| ctx.lineTo(chartWidth, y);
|
| ctx.stroke();
|
|
|
| ctx.fillText(price.toFixed(2), chartWidth + 5, y + 4);
|
| }
|
|
|
|
|
|
|
|
|
|
|
| visibleData.forEach((candle, i) => {
|
| const originalIndex = leftIndex + i;
|
| const x = getX(originalIndex);
|
|
|
| const yOpen = getY(candle.open);
|
| const yClose = getY(candle.close);
|
| const yHigh = getY(candle.high);
|
| const yLow = getY(candle.low);
|
|
|
| const isUp = candle.close >= candle.open;
|
| const color = isUp ? colors.up : colors.down;
|
|
|
| ctx.fillStyle = color;
|
| ctx.strokeStyle = color;
|
| ctx.lineWidth = 1;
|
|
|
|
|
| ctx.beginPath();
|
| ctx.moveTo(x, yHigh);
|
| ctx.lineTo(x, yLow);
|
| ctx.stroke();
|
|
|
|
|
| const bodyH = Math.max(Math.abs(yClose - yOpen), 1);
|
| ctx.fillRect(x - candleWidth / 2, Math.min(yOpen, yClose), candleWidth, bodyH);
|
| });
|
|
|
|
|
| if (tick && tick.bid) {
|
| const yBid = getY(tick.bid);
|
| if (yBid >= 0 && yBid <= height) {
|
| ctx.strokeStyle = colors.crosshair;
|
| ctx.setLineDash([4, 4]);
|
| ctx.beginPath();
|
| ctx.moveTo(0, yBid);
|
| ctx.lineTo(chartWidth, yBid);
|
| ctx.stroke();
|
| ctx.setLineDash([]);
|
|
|
|
|
| ctx.fillStyle = colors.crosshair;
|
| ctx.fillRect(chartWidth, yBid - 10, 60, 20);
|
| ctx.fillStyle = '#000';
|
| ctx.fillText(tick.bid.toFixed(2), chartWidth + 5, yBid + 4);
|
| }
|
| }
|
| }, [data, tick, viewport, colors]);
|
|
|
|
|
| useEffect(() => {
|
| draw();
|
| }, [draw]);
|
|
|
|
|
| const handleMouseDown = (e) => {
|
| setIsDragging(true);
|
| lastMouseX.current = e.clientX;
|
| };
|
|
|
| const handleMouseMove = (e) => {
|
| if (!isDragging) return;
|
| const deltaX = e.clientX - lastMouseX.current;
|
| lastMouseX.current = e.clientX;
|
|
|
| setViewport(prev => ({
|
| ...prev,
|
| offset: prev.offset - deltaX
|
| }));
|
| };
|
|
|
| const handleMouseUp = () => {
|
| setIsDragging(false);
|
| };
|
|
|
| const handleWheel = (e) => {
|
| e.preventDefault();
|
| const zoomSensitivity = 0.001;
|
| setViewport(prev => {
|
| const newScale = Math.max(2, Math.min(50, prev.scale * (1 - e.deltaY * zoomSensitivity)));
|
| return { ...prev, scale: newScale };
|
| });
|
| };
|
|
|
| return (
|
| <div
|
| ref={containerRef}
|
| className="chart-container"
|
| onMouseDown={handleMouseDown}
|
| onMouseMove={handleMouseMove}
|
| onMouseUp={handleMouseUp}
|
| onMouseLeave={handleMouseUp}
|
| onWheel={handleWheel}
|
| style={{
|
| cursor: isDragging ? 'grabbing' : 'grab',
|
| touchAction: 'none',
|
| width: '100%',
|
| height: '100%',
|
| position: 'relative'
|
| }}
|
| >
|
| <canvas ref={canvasRef} style={{ display: 'block' }} />
|
|
|
| {/* Simple OHLC overlay */}
|
| {data && data.length > 0 && (
|
| <div style={{
|
| position: 'absolute',
|
| top: 10,
|
| left: 10,
|
| color: '#8b949e',
|
| fontFamily: 'monospace',
|
| fontSize: '12px',
|
| pointerEvents: 'none'
|
| }}>
|
| Last: O:{data[data.length - 1].open.toFixed(2)} H:{data[data.length - 1].high.toFixed(2)} L:{data[data.length - 1].low.toFixed(2)} C:{data[data.length - 1].close.toFixed(2)}
|
| </div>
|
| )}
|
| </div>
|
| );
|
| }
|
|
|