first commit
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-20 17:31:01 +01:00
commit 61ab24490d
160 changed files with 17034 additions and 0 deletions
@@ -0,0 +1,232 @@
import { useMemo, useRef, useEffect, useCallback } from 'react';
import type { OHLCVBar, SRLevel } from '../../lib/types';
import { formatPrice, formatDate } from '../../lib/format';
interface CandlestickChartProps {
data: OHLCVBar[];
srLevels?: SRLevel[];
maxSRLevels?: number;
}
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
if (levels.length <= max) return levels;
return [...levels].sort((a, b) => b.strength - a.strength).slice(0, max);
}
interface TooltipState {
visible: boolean;
x: number;
y: number;
bar: OHLCVBar | null;
}
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6 }: CandlestickChartProps) {
const canvasRef = 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 animFrame = useRef<number>(0);
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
const W = rect.width;
const H = 400;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = `${W}px`;
canvas.style.height = `${H}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
// Margins
const ml = 12, mr = 70, mt = 12, mb = 32;
const cw = W - ml - mr;
const ch = H - mt - mb;
// Price range
const allPrices = data.flatMap((b) => [b.high, b.low]);
const srPrices = topLevels.map((l) => l.price_level);
const allVals = [...allPrices, ...srPrices];
const minP = Math.min(...allVals);
const maxP = Math.max(...allVals);
const pad = (maxP - minP) * 0.06 || 1;
const lo = minP - pad;
const hi = maxP + pad;
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
const barW = cw / data.length;
const candleW = Math.max(barW * 0.65, 1);
// Grid lines (horizontal)
const nTicks = 6;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.fillStyle = '#6b7280';
ctx.font = '11px Inter, system-ui, sans-serif';
ctx.textAlign = 'right';
for (let i = 0; i <= nTicks; i++) {
const v = lo + ((hi - lo) * i) / nTicks;
const y = yScale(v);
ctx.beginPath();
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.fillText(formatPrice(v), W - 8, y + 4);
}
// 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 x = ml + i * barW + barW / 2;
ctx.fillStyle = '#6b7280';
ctx.fillText(formatDate(data[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';
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
);
});
// Candles
data.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const color = bullish ? '#10b981' : '#ef4444';
const yHigh = yScale(bar.high);
const yLow = yScale(bar.low);
const yOpen = yScale(bar.open);
const yClose = yScale(bar.close);
// Wick
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
// Body
const bodyTop = Math.min(yOpen, yClose);
const bodyH = Math.max(Math.abs(yOpen - yClose), 1);
ctx.fillStyle = color;
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]);
useEffect(() => {
draw();
const onResize = () => {
cancelAnimationFrame(animFrame.current);
animFrame.current = requestAnimationFrame(draw);
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(animFrame.current);
};
}, [draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const tip = tooltipRef.current;
if (!canvas || !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);
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 };
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.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">
<span>Open</span><span class="text-right text-gray-200">${formatPrice(bar.open)}</span>
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
<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>`;
} else {
tip.style.display = 'none';
}
},
[data]
);
const handleMouseLeave = useCallback(() => {
const tip = tooltipRef.current;
if (tip) tip.style.display = 'none';
}, []);
if (data.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
No OHLCV data available
</div>
);
}
return (
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
<canvas
ref={canvasRef}
className="w-full cursor-crosshair"
style={{ height: 400 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
<div
ref={tooltipRef}
className="glass absolute pointer-events-none px-3 py-2 text-xs shadow-2xl z-50"
style={{ display: 'none' }}
/>
</div>
);
}