import { useRef, useEffect, useState } from 'react' interface PriceCandle { time: string open: number high: number low: number close: number volume: number } interface LinePoint { time: string value: number } interface ChartEvent { date: string title: string level: string category?: string impact_score?: number end_date?: string | null description?: string } interface TheoPoint { date: string cumulative_pips: number contributions: { template_name: string; event_name: string; event_date: string; pips: number; decay_factor: number }[] } interface Props { priceData: PriceCandle[] indicators: Record events?: ChartEvent[] height?: number chartType?: 'candles' | 'line' onDateHover?: (date: string | null) => void theoryCurve?: TheoPoint[] /** Called once the chart is ready with (dateToCoord, canvasPageLeft) — lets external components align with the chart's x-axis */ onChartReady?: (dateToCoord: (d: string) => number | null, canvasPageLeft: number) => void } export type { TheoPoint } const MA_COLORS: Record = { ma20: '#f59e0b', ma50: '#3b82f6', ma100: '#22d3ee', ma200: '#a855f7', } // Every overlay a user can show/hide independently. 'bb' toggles the upper+lower band pair // together (they're one visual concept); 'volatility' gets its own price scale since // annualized % lives on a totally different axis than the price series. type OverlayKey = 'ma20' | 'ma50' | 'ma100' | 'ma200' | 'bb' | 'volatility' const OVERLAY_KEYS: OverlayKey[] = ['ma20', 'ma50', 'ma100', 'ma200', 'bb', 'volatility'] const OVERLAY_META: Record = { ma20: { label: 'MA20', color: MA_COLORS.ma20, defaultVisible: true }, ma50: { label: 'MA50', color: MA_COLORS.ma50, defaultVisible: true }, ma100: { label: 'MA100', color: MA_COLORS.ma100, defaultVisible: true }, ma200: { label: 'MA200', color: MA_COLORS.ma200, defaultVisible: true }, bb: { label: 'BB(20,2)', color: '#94a3b8', dashed: true, defaultVisible: true }, volatility: { label: 'Volatilité', color: '#ec4899', defaultVisible: false }, } const CAT_COLORS: Record = { event_calendar: '#f59e0b', geopolitical: '#ef4444', fundamental: '#10b981', report: '#3b82f6', sentiment: '#8b5cf6', technical: '#06b6d4', } const CAT_LABELS: Record = { event_calendar: 'Cal.', geopolitical: 'Géo.', fundamental: 'Fund.', report: 'Report', sentiment: 'Sent.', technical: 'Tech.', } export default function InstrumentChart({ priceData, indicators, events = [], height = 420, chartType = 'candles', onDateHover, theoryCurve, onChartReady }: Props) { const containerRef = useRef(null) const cleanupRef = useRef<(() => void) | null>(null) const onHoverRef = useRef(onDateHover) const onChartReadyRef = useRef(onChartReady) const chartRef = useRef(null) const seriesRefs = useRef>>({}) // Which overlays have any data at all for THIS instrument — only these get a toggle pill. const availableOverlays = OVERLAY_KEYS.filter(k => k === 'bb' ? (indicators.bb_upper?.length && indicators.bb_lower?.length) : indicators[k]?.length ) const [visible, setVisible] = useState>(() => Object.fromEntries(OVERLAY_KEYS.map(k => [k, OVERLAY_META[k].defaultVisible])) as Record ) // Read at chart-(re)build time without forcing a rebuild on every toggle — toggling // itself is handled cheaply below via each series' own applyOptions({visible}). const visibleRef = useRef(visible) useEffect(() => { visibleRef.current = visible }, [visible]) const toggleOverlay = (key: OverlayKey) => { setVisible(v => { const next = { ...v, [key]: !v[key] } seriesRefs.current[key]?.forEach(s => s.applyOptions({ visible: next[key] })) if (key === 'volatility') { chartRef.current?.priceScale('volatility').applyOptions({ visible: next[key] }) } return next }) } // Keep callback refs fresh without triggering chart rebuild useEffect(() => { onHoverRef.current = onDateHover }, [onDateHover]) useEffect(() => { onChartReadyRef.current = onChartReady }, [onChartReady]) useEffect(() => { cleanupRef.current?.() cleanupRef.current = null if (!containerRef.current || !priceData.length) return let active = true import('lightweight-charts').then((lc: any) => { if (!active || !containerRef.current) return const { createChart, CrosshairMode, ColorType, LineStyle } = lc const chart = createChart(containerRef.current, { width: containerRef.current.clientWidth, height, layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#64748b', fontSize: 11, }, grid: { vertLines: { color: 'rgba(255,255,255,0.04)' }, horzLines: { color: 'rgba(255,255,255,0.04)' }, }, crosshair: { mode: CrosshairMode.Normal }, rightPriceScale: { borderColor: 'rgba(255,255,255,0.1)' }, timeScale: { borderColor: 'rgba(255,255,255,0.1)', timeVisible: true, secondsVisible: false, fixLeftEdge: true, fixRightEdge: true, }, handleScroll: true, handleScale: true, }) chartRef.current = chart seriesRefs.current = {} // Volume (bottom 18%) const totalVol = priceData.reduce((s, d) => s + (d.volume || 0), 0) if (totalVol > 0) { const volSeries = chart.addHistogramSeries({ color: 'rgba(148,163,184,0.2)', priceFormat: { type: 'volume' }, priceScaleId: 'volume', }) chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } }) volSeries.setData(priceData.map(d => ({ time: d.time, value: d.volume, color: d.close >= d.open ? 'rgba(16,185,129,0.25)' : 'rgba(239,68,68,0.25)', }))) } // Prix principal — chandeliers ou courbe lisse if (chartType === 'line') { const areaSeries = chart.addAreaSeries({ lineColor: '#3b82f6', topColor: 'rgba(59,130,246,0.18)', bottomColor: 'rgba(59,130,246,0.01)', lineWidth: 2, priceLineVisible: true, lastValueVisible: true, crosshairMarkerVisible: true, crosshairMarkerRadius: 4, }) areaSeries.setData(priceData.map(d => ({ time: d.time as any, value: d.close }))) } else { const candleSeries = chart.addCandlestickSeries({ upColor: '#10b981', downColor: '#ef4444', borderUpColor: '#10b981', borderDownColor: '#ef4444', wickUpColor: '#6ee7b7', wickDownColor: '#fca5a5', }) candleSeries.setData(priceData) } // MA lines — each individually toggleable for (const [key, color] of Object.entries(MA_COLORS)) { const data = indicators[key] if (data?.length) { const series = chart.addLineSeries({ color, lineWidth: key === 'ma200' ? 1.5 : 1, priceLineVisible: false, lastValueVisible: true, crosshairMarkerVisible: false, visible: visibleRef.current[key as OverlayKey], }) series.setData(data) seriesRefs.current[key as OverlayKey] = [series] } } // Bollinger Bands — upper+lower toggle together as one 'bb' overlay if (indicators.bb_upper?.length && indicators.bb_lower?.length) { const bbOpts = { color: 'rgba(148,163,184,0.32)', lineWidth: 1, lineStyle: LineStyle.Dashed, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, visible: visibleRef.current.bb, } const bbUpper = chart.addLineSeries(bbOpts) bbUpper.setData(indicators.bb_upper) const bbLower = chart.addLineSeries(bbOpts) bbLower.setData(indicators.bb_lower) seriesRefs.current.bb = [bbUpper, bbLower] } // Volatilité réalisée (annualisée, %) — échelle dédiée, cachée par défaut if (indicators.volatility?.length) { chart.priceScale('volatility').applyOptions({ visible: visibleRef.current.volatility, borderColor: 'rgba(236,72,153,0.25)', scaleMargins: { top: 0.78, bottom: 0.02 }, }) const volatilitySeries = chart.addLineSeries({ color: '#ec4899', lineWidth: 1, priceScaleId: 'volatility', title: 'Vol. réalisée %', priceLineVisible: false, lastValueVisible: true, crosshairMarkerVisible: true, crosshairMarkerRadius: 3, visible: visibleRef.current.volatility, }) volatilitySeries.setData(indicators.volatility) seriesRefs.current.volatility = [volatilitySeries] } // ── Theoretical curve — left scale (pips) ───────────────────────────── if (theoryCurve?.length) { chart.priceScale('left').applyOptions({ visible: true, borderColor: 'rgba(167,139,250,0.25)', textColor: '#a78bfa', scaleMargins: { top: 0.1, bottom: 0.1 }, }) const theorySeries = chart.addLineSeries({ color: 'rgba(167,139,250,0.75)', lineWidth: 1.5, priceScaleId: 'left', title: 'Δ pips théorique', priceLineVisible: false, lastValueVisible: true, crosshairMarkerVisible: true, crosshairMarkerRadius: 3, }) const theoryData = theoryCurve .filter(p => p.contributions.length > 0) .map(p => ({ time: p.date as any, value: p.cumulative_pips })) if (theoryData.length) theorySeries.setData(theoryData) } chart.timeScale().fitContent() // Expose coordinate mapping so siblings can align with the chart x-axis if (onChartReadyRef.current && containerRef.current) { const canvasLeft = containerRef.current.getBoundingClientRect().left onChartReadyRef.current((d: string) => chart.timeScale().timeToCoordinate(d as any), canvasLeft) } // ── Star overlay — ★ icons positioned via chart coordinate API ──────── const candleMap: Record = {} for (const c of priceData) candleMap[c.time] = c.high const starsOverlay = document.createElement('div') starsOverlay.style.cssText = [ 'position:absolute', 'top:0', 'left:0', 'width:100%', 'height:100%', 'pointer-events:none', 'overflow:hidden', 'z-index:10', ].join(';') containerRef.current!.appendChild(starsOverlay) const renderStars = () => { if (!active) return starsOverlay.innerHTML = '' const cw = containerRef.current?.clientWidth ?? 0 // ── Collect visible events with their x coords ────────────────────── type Placed = { xCoord: number; halfW: number; ev: typeof events[0] } const visible: Placed[] = [] for (const ev of events) { const xCoord = chart.timeScale().timeToCoordinate(ev.date as any) if (xCoord === null || xCoord < 0 || xCoord > cw) continue const title = ev.title.slice(0, 20) const halfW = Math.max(30, title.length * 6 + 12) // approx px half-width visible.push({ xCoord, halfW, ev }) } // ── Greedy row placement (up to 4 rows, 20px apart) ───────────────── const ROW_H = 20 // pixels between rows const MAX_ROWS = 4 // rowEnds[r] = rightmost x already occupied in row r const rowEnds: number[] = Array(MAX_ROWS).fill(-Infinity) // Sort by x so greedy works left-to-right visible.sort((a, b) => a.xCoord - b.xCoord) for (const { xCoord, halfW, ev } of visible) { const left = xCoord - halfW const right = xCoord + halfW + 4 // Find first row where the label fits (no overlap) let row = 0 for (let r = 0; r < MAX_ROWS; r++) { if (rowEnds[r] <= left) { row = r; break } if (r === MAX_ROWS - 1) row = r // last resort: use bottom row } rowEnds[row] = right const yBase = 6 + row * ROW_H const cat = ev.category ?? 'fundamental' const color = CAT_COLORS[cat] ?? '#94a3b8' const score = ev.impact_score ?? 0.5 const sz = score >= 0.8 ? 16 : score >= 0.6 ? 13 : 11 const wrap = document.createElement('div') wrap.style.cssText = [ `position:absolute`, `left:${xCoord}px`, `top:${yBase}px`, `transform:translateX(-50%)`, `display:flex`, `align-items:center`, `gap:3px`, `pointer-events:auto`, `cursor:default`, ].join(';') wrap.title = `${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}` const star = document.createElement('span') star.style.cssText = [ `font-size:${sz}px`, `color:${color}`, `line-height:1`, `filter:drop-shadow(0 0 3px ${color}80)`, ].join(';') star.textContent = '★' const lbl = document.createElement('span') lbl.style.cssText = [ `font-size:10px`, `font-family:ui-monospace,monospace`, `color:${color}`, `white-space:nowrap`, `text-shadow:0 1px 4px #000,0 0 6px #000`, `line-height:1`, `max-width:100px`, `overflow:hidden`, `text-overflow:ellipsis`, `font-weight:600`, ].join(';') lbl.textContent = ev.title.slice(0, 20) wrap.appendChild(star) wrap.appendChild(lbl) starsOverlay.appendChild(wrap) } } renderStars() chart.timeScale().subscribeVisibleLogicalRangeChange(renderStars) // Crosshair → date callback chart.subscribeCrosshairMove((param: any) => { if (param?.time && typeof param.time === 'string') { onHoverRef.current?.(param.time) } else if (!param?.point) { onHoverRef.current?.(null) // mouse left chart } }) const ro = new ResizeObserver(() => { if (containerRef.current) chart.applyOptions({ width: containerRef.current.clientWidth }) }) ro.observe(containerRef.current) cleanupRef.current = () => { ro.disconnect() chart.timeScale().unsubscribeVisibleLogicalRangeChange(renderStars) if (starsOverlay.parentNode) starsOverlay.parentNode.removeChild(starsOverlay) chart.remove() chartRef.current = null seriesRefs.current = {} onChartReadyRef.current?.(null as any, 0) } }) return () => { active = false cleanupRef.current?.() cleanupRef.current = null } // `visible` intentionally excluded — toggling is handled directly via series refs // above, without tearing down and rebuilding the whole chart. // eslint-disable-next-line react-hooks/exhaustive-deps }, [priceData, indicators, events, height, chartType, theoryCurve]) return (
{availableOverlays.map(key => { const meta = OVERLAY_META[key] const on = visible[key] return ( ) })} {theoryCurve?.some(p => p.contributions.length > 0) && ( Théorie (pips) )} {Object.entries(CAT_COLORS).map(([cat, c]) => ( {CAT_LABELS[cat]} ))}
{!priceData.length ? (
Chargement...
) : (
)}
) }