major update
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-27 16:08:09 +01:00
parent 61ab24490d
commit 181cfe6588
71 changed files with 7647 additions and 281 deletions
@@ -1,11 +1,13 @@
import { useMemo, useRef, useEffect, useCallback } from 'react';
import type { OHLCVBar, SRLevel } from '../../lib/types';
import { useMemo, useRef, useEffect, useCallback, useState } from 'react';
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format';
interface CandlestickChartProps {
data: OHLCVBar[];
srLevels?: SRLevel[];
maxSRLevels?: number;
zones?: SRZone[];
tradeSetup?: TradeSetup;
}
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
@@ -20,12 +22,29 @@ interface TooltipState {
bar: OHLCVBar | null;
}
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: CandlestickChartProps) {
const MIN_VISIBLE_BARS = 10;
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = [], tradeSetup }: CandlestickChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipState = useRef<TooltipState>({ visible: false, x: 0, y: 0, bar: null });
const crosshairRef = useRef<{ x: number; y: number } | null>(null);
const animFrame = useRef<number>(0);
const zoomFrame = useRef<number>(0);
const isPanningRef = useRef<boolean>(false);
const panStartXRef = useRef<number>(0);
const [visibleRange, setVisibleRange] = useState<{ start: number; end: number }>({
start: 0,
end: data.length,
});
// Reset visible range when data changes
useEffect(() => {
setVisibleRange({ start: 0, end: data.length });
}, [data]);
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
@@ -34,6 +53,12 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
// Clamp visible range to valid bounds
const start = Math.max(0, visibleRange.start);
const end = Math.min(data.length, visibleRange.end);
const visibleData = data.slice(start, end);
if (visibleData.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const W = rect.width;
@@ -54,10 +79,12 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
const cw = W - ml - mr;
const ch = H - mt - mb;
// Price range
const allPrices = data.flatMap((b) => [b.high, b.low]);
// Price range from visible data
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
const srPrices = topLevels.map((l) => l.price_level);
const allVals = [...allPrices, ...srPrices];
const zonePrices = zones.flatMap((z) => [z.low, z.high]);
const tradePrices = tradeSetup ? [tradeSetup.entry_price, tradeSetup.stop_loss, tradeSetup.target] : [];
const allVals = [...allPrices, ...srPrices, ...zonePrices, ...tradePrices];
const minP = Math.min(...allVals);
const maxP = Math.max(...allVals);
const pad = (maxP - minP) * 0.06 || 1;
@@ -65,7 +92,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
const hi = maxP + pad;
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / data.length;
const barW = cw / visibleData.length;
const candleW = Math.max(barW * 0.65, 1);
// Grid lines (horizontal)
@@ -87,43 +114,142 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
// X-axis labels
ctx.textAlign = 'center';
const labelInterval = Math.max(Math.floor(data.length / 8), 1);
for (let i = 0; i < data.length; i += labelInterval) {
const labelInterval = Math.max(Math.floor(visibleData.length / 8), 1);
for (let i = 0; i < visibleData.length; i += labelInterval) {
const x = ml + i * barW + barW / 2;
ctx.fillStyle = '#6b7280';
ctx.fillText(formatDate(data[i].date), x, H - 6);
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
}
// S/R levels
topLevels.forEach((level) => {
const y = yScale(level.price_level);
const isSupport = level.type === 'support';
const color = isSupport ? '#10b981' : '#ef4444';
// S/R levels (only when no zones are provided)
if (zones.length === 0) {
topLevels.forEach((level) => {
const y = yScale(level.price_level);
const isSupport = level.type === 'support';
const color = isSupport ? '#10b981' : '#ef4444';
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.55;
ctx.setLineDash([6, 3]);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.55;
ctx.setLineDash([6, 3]);
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Label
ctx.fillStyle = color;
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
ml + cw + 4,
y + 3
);
});
}
// S/R Zone rectangles (drawn before candles so candles render on top)
zones.forEach((zone) => {
const isSupport = zone.type === 'support';
const fillColor = isSupport ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)';
const borderColor = isSupport ? 'rgba(16, 185, 129, 0.35)' : 'rgba(239, 68, 68, 0.35)';
const labelColor = isSupport ? '#10b981' : '#ef4444';
const yTop = yScale(zone.high);
const yBottom = yScale(zone.low);
// Handle single-level zones (low == high) as thin 2px-height rectangles
const rectHeight = Math.max(yBottom - yTop, 2);
// Shaded rectangle spanning full chart width
ctx.fillStyle = fillColor;
ctx.fillRect(ml, yTop, cw, rectHeight);
// Border lines at top and bottom of zone
ctx.strokeStyle = borderColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.moveTo(ml, yTop);
ctx.lineTo(ml + cw, yTop);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ml, yTop + rectHeight);
ctx.lineTo(ml + cw, yTop + rectHeight);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Label
ctx.fillStyle = color;
// Label with midpoint price and strength score
const yMid = yTop + rectHeight / 2;
ctx.fillStyle = labelColor;
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
`${zone.type[0].toUpperCase()} ${formatPrice(zone.midpoint)} (${zone.strength})`,
ml + cw + 4,
y + 3
yMid + 3
);
});
// Trade setup overlay (drawn before candles so candles render on top)
if (tradeSetup) {
const entryY = yScale(tradeSetup.entry_price);
const stopY = yScale(tradeSetup.stop_loss);
const targetY = yScale(tradeSetup.target);
// Stop-loss zone: red semi-transparent rectangle between entry and stop-loss
const slTop = Math.min(entryY, stopY);
const slHeight = Math.max(Math.abs(stopY - entryY), 1);
ctx.fillStyle = 'rgba(239, 68, 68, 0.13)';
ctx.fillRect(ml, slTop, cw, slHeight);
// Stop-loss border
ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(ml, stopY);
ctx.lineTo(ml + cw, stopY);
ctx.stroke();
ctx.setLineDash([]);
// Take-profit zone: green semi-transparent rectangle between entry and target
const tpTop = Math.min(entryY, targetY);
const tpHeight = Math.max(Math.abs(targetY - entryY), 1);
ctx.fillStyle = 'rgba(16, 185, 129, 0.13)';
ctx.fillRect(ml, tpTop, cw, tpHeight);
// Target border
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(ml, targetY);
ctx.lineTo(ml + cw, targetY);
ctx.stroke();
ctx.setLineDash([]);
// Entry price: dashed horizontal line (blue/white)
ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(ml, entryY);
ctx.lineTo(ml + cw, entryY);
ctx.stroke();
ctx.setLineDash([]);
// Labels on right side
ctx.font = '10px Inter, system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
ctx.fillStyle = 'rgba(16, 185, 129, 0.8)';
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
}
// Candles
data.forEach((bar, i) => {
visibleData.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const color = bullish ? '#10b981' : '#ef4444';
@@ -148,42 +274,307 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
});
// Store geometry for hit testing
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale };
}, [data, topLevels]);
// Store geometry for hit testing (includes visibleRange offset)
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
// Size the overlay canvas to match
const overlay = overlayCanvasRef.current;
if (overlay) {
overlay.width = W * dpr;
overlay.height = H * dpr;
overlay.style.width = `${W}px`;
overlay.style.height = `${H}px`;
}
}, [data, topLevels, visibleRange, zones, tradeSetup]);
const drawCrosshair = useCallback(() => {
const overlay = overlayCanvasRef.current;
const canvas = canvasRef.current;
if (!overlay || !canvas) return;
const dpr = window.devicePixelRatio || 1;
const ctx = overlay.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, overlay.width, overlay.height);
const pos = crosshairRef.current;
if (!pos) return;
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
const H = overlay.height / dpr;
// Clamp crosshair to chart area
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
// Dashed crosshair lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 0.75;
ctx.setLineDash([4, 3]);
// Vertical line
ctx.beginPath();
ctx.moveTo(cx, mt);
ctx.lineTo(cx, mt + ch);
ctx.stroke();
// Horizontal line
ctx.beginPath();
ctx.moveTo(ml, cy);
ctx.lineTo(ml + cw, cy);
ctx.stroke();
ctx.setLineDash([]);
// Price label on y-axis (right side)
const price = hi - ((cy - mt) / ch) * (hi - lo);
const priceText = formatPrice(price);
ctx.font = '11px Inter, system-ui, sans-serif';
const priceMetrics = ctx.measureText(priceText);
const labelPadX = 5;
const labelPadY = 3;
const labelW = priceMetrics.width + labelPadX * 2;
const labelH = 16 + labelPadY * 2;
const labelX = ml + cw + 2;
const labelY = cy - labelH / 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy);
// Date label on x-axis (bottom)
const localIdx = Math.floor((cx - ml) / barW);
const absIdx = (visibleStart ?? 0) + localIdx;
if (absIdx >= 0 && absIdx < data.length) {
const dateText = formatDate(data[absIdx].date);
const dateMetrics = ctx.measureText(dateText);
const dateLabelW = dateMetrics.width + labelPadX * 2;
const dateLabelH = 16 + labelPadY * 2;
const dateLabelX = cx - dateLabelW / 2;
const dateLabelY = H - mb + 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.beginPath();
ctx.roundRect(dateLabelX, dateLabelY, dateLabelW, dateLabelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(dateText, cx, dateLabelY + dateLabelH / 2);
}
}, [data]);
useEffect(() => {
draw();
const onResize = () => {
cancelAnimationFrame(animFrame.current);
animFrame.current = requestAnimationFrame(draw);
animFrame.current = requestAnimationFrame(() => {
draw();
drawCrosshair();
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(animFrame.current);
};
}, [draw]);
}, [draw, drawCrosshair]);
// Prevent default scroll on wheel events over the overlay canvas
useEffect(() => {
const overlay = overlayCanvasRef.current;
if (!overlay) return;
const preventScroll = (e: WheelEvent) => {
if (data.length >= MIN_VISIBLE_BARS) {
e.preventDefault();
}
};
overlay.addEventListener('wheel', preventScroll, { passive: false });
return () => overlay.removeEventListener('wheel', preventScroll);
}, [data.length]);
const handleWheel = useCallback(
(e: React.WheelEvent<HTMLCanvasElement>) => {
// Disable zoom if dataset has fewer than 10 bars
if (data.length < MIN_VISIBLE_BARS) return;
cancelAnimationFrame(zoomFrame.current);
zoomFrame.current = requestAnimationFrame(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
// Determine cursor position as fraction within the visible chart area
const cursorFraction = Math.max(0, Math.min(1, (mx - meta.ml) / meta.cw));
setVisibleRange((prev) => {
const currentWidth = prev.end - prev.start;
const zoomFactor = 0.1;
let delta: number;
if (e.deltaY > 0) {
// Scroll down → zoom out (widen range)
delta = Math.max(1, Math.round(currentWidth * zoomFactor));
} else {
// Scroll up → zoom in (narrow range)
delta = -Math.max(1, Math.round(currentWidth * zoomFactor));
}
const newWidth = currentWidth + delta;
// Clamp to min 10 bars, max full dataset
const clampedWidth = Math.max(MIN_VISIBLE_BARS, Math.min(data.length, newWidth));
if (clampedWidth === currentWidth) return prev;
const widthChange = clampedWidth - currentWidth;
// Distribute the change around the cursor position
const leftChange = Math.round(widthChange * cursorFraction);
const rightChange = widthChange - leftChange;
let newStart = prev.start - leftChange;
let newEnd = prev.end + rightChange;
// Clamp to dataset bounds
if (newStart < 0) {
newEnd -= newStart;
newStart = 0;
}
if (newEnd > data.length) {
newStart -= newEnd - data.length;
newEnd = data.length;
}
newStart = Math.max(0, newStart);
newEnd = Math.min(data.length, newEnd);
return { start: newStart, end: newEnd };
});
});
},
[data.length]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
// Pan only when zoomed in (visible range < full dataset)
const currentWidth = visibleRange.end - visibleRange.start;
if (currentWidth >= data.length) return;
isPanningRef.current = true;
panStartXRef.current = e.clientX;
},
[data.length, visibleRange]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Handle panning
if (isPanningRef.current) {
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const dx = e.clientX - panStartXRef.current;
// Convert pixel drag to bar count: negative dx = drag left = shift range right (see later bars)
const barShift = -Math.round(dx / meta.barW);
if (barShift !== 0) {
panStartXRef.current = e.clientX;
setVisibleRange((prev) => {
const width = prev.end - prev.start;
let newStart = prev.start + barShift;
let newEnd = prev.end + barShift;
// Clamp to dataset bounds
if (newStart < 0) {
newStart = 0;
newEnd = width;
}
if (newEnd > data.length) {
newEnd = data.length;
newStart = data.length - width;
}
if (newStart === prev.start && newEnd === prev.end) return prev;
return { start: newStart, end: newEnd };
});
}
// Clear crosshair while panning
crosshairRef.current = null;
drawCrosshair();
return;
}
// Update crosshair position and draw
crosshairRef.current = { x: mx, y: my };
drawCrosshair();
// Tooltip logic
const tip = tooltipRef.current;
if (!canvas || !tip || data.length === 0) return;
if (!tip || data.length === 0) return;
const meta = (canvas as any).__chartMeta;
if (!meta) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const idx = Math.floor((mx - meta.ml) / meta.barW);
const localIdx = Math.floor((mx - meta.ml) / meta.barW);
if (idx >= 0 && idx < data.length) {
const bar = data[idx];
tooltipState.current = { visible: true, x: e.clientX - rect.left, y: e.clientY - rect.top, bar };
// Map local index to the visible data slice
const visibleStart = meta.visibleStart ?? 0;
const visibleEnd = Math.min(data.length, visibleStart + Math.round(meta.cw / meta.barW));
const absIdx = visibleStart + localIdx;
if (localIdx >= 0 && absIdx < visibleEnd && absIdx < data.length) {
const bar = data[absIdx];
tooltipState.current = { visible: true, x: mx, y: my, bar };
tip.style.display = 'block';
tip.style.left = `${Math.min(mx + 14, rect.width - 180)}px`;
tip.style.top = `${Math.max(e.clientY - rect.top - 80, 8)}px`;
tip.style.top = `${Math.max(my - 80, 8)}px`;
// Check if cursor is near trade overlay zone
let tradeTooltipHtml = '';
if (tradeSetup && meta.yScale) {
const entryY = meta.yScale(tradeSetup.entry_price);
const stopY = meta.yScale(tradeSetup.stop_loss);
const targetY = meta.yScale(tradeSetup.target);
const tradeTop = Math.min(entryY, stopY, targetY);
const tradeBottom = Math.max(entryY, stopY, targetY);
if (my >= tradeTop - 10 && my <= tradeBottom + 10) {
tradeTooltipHtml = `
<div class="border-t border-gray-600 mt-1.5 pt-1.5 text-gray-300 font-medium mb-1">Trade Setup</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
<span>Direction</span><span class="text-right text-gray-200">${tradeSetup.direction}</span>
<span>Entry</span><span class="text-right text-blue-300">${formatPrice(tradeSetup.entry_price)}</span>
<span>Stop</span><span class="text-right text-red-400">${formatPrice(tradeSetup.stop_loss)}</span>
<span>Target</span><span class="text-right text-green-400">${formatPrice(tradeSetup.target)}</span>
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
</div>`;
}
}
tip.innerHTML = `
<div class="text-gray-300 font-medium mb-1">${formatDate(bar.date)}</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
@@ -192,18 +583,25 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
</div>`;
</div>${tradeTooltipHtml}`;
} else {
tip.style.display = 'none';
}
},
[data]
[data, drawCrosshair, tradeSetup]
);
const handleMouseUp = useCallback(() => {
isPanningRef.current = false;
}, []);
const handleMouseLeave = useCallback(() => {
isPanningRef.current = false;
crosshairRef.current = null;
drawCrosshair();
const tip = tooltipRef.current;
if (tip) tip.style.display = 'none';
}, []);
}, [drawCrosshair]);
if (data.length === 0) {
return (
@@ -217,10 +615,18 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: Candl
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<canvas
ref={canvasRef}
className="w-full cursor-crosshair"
className="w-full"
style={{ height: 400 }}
/>
<canvas
ref={overlayCanvasRef}
className="absolute top-0 left-0 w-full cursor-crosshair"
style={{ height: 400 }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onWheel={handleWheel}
/>
<div
ref={tooltipRef}