Spaces:
Running
Running
| /** | |
| * Chart Manager — TradingView Lightweight Charts v4 | |
| * Renders candlestick chart with overlays for OBs, zones, EMAs, and signals. | |
| */ | |
| import { createChart, ColorType, LineStyle, CrosshairMode } from 'lightweight-charts'; | |
| export class ChartManager { | |
| constructor(containerId) { | |
| this.container = document.getElementById(containerId); | |
| this.chart = null; | |
| this.candleSeries = null; | |
| this.volumeSeries = null; | |
| this.emaLines = {}; | |
| this.markers = []; | |
| this.drawnObjects = { zones: [], orderBlocks: [], signalLines: [] }; | |
| this._init(); | |
| } | |
| _init() { | |
| this.chart = createChart(this.container, { | |
| layout: { | |
| background: { type: ColorType.Solid, color: 'transparent' }, | |
| textColor: '#8b92a5', | |
| fontFamily: "'Inter', sans-serif", | |
| fontSize: 11, | |
| }, | |
| grid: { | |
| vertLines: { color: 'rgba(255, 255, 255, 0.03)' }, | |
| horzLines: { color: 'rgba(255, 255, 255, 0.03)' }, | |
| }, | |
| crosshair: { | |
| mode: CrosshairMode.Normal, | |
| vertLine: { | |
| color: 'rgba(59, 130, 246, 0.3)', | |
| width: 1, | |
| style: LineStyle.Dashed, | |
| labelBackgroundColor: '#3b82f6', | |
| }, | |
| horzLine: { | |
| color: 'rgba(59, 130, 246, 0.3)', | |
| width: 1, | |
| style: LineStyle.Dashed, | |
| labelBackgroundColor: '#3b82f6', | |
| }, | |
| }, | |
| rightPriceScale: { | |
| borderColor: 'rgba(255, 255, 255, 0.06)', | |
| scaleMargins: { top: 0.12, bottom: 0.12 }, | |
| autoScale: true, | |
| }, | |
| timeScale: { | |
| borderColor: 'rgba(255, 255, 255, 0.06)', | |
| timeVisible: true, | |
| secondsVisible: false, | |
| rightOffset: 5, | |
| barSpacing: 8, | |
| }, | |
| handleScroll: { | |
| mouseWheel: true, | |
| pressedMouseButton: true, | |
| horzTouchDrag: true, | |
| vertTouchDrag: true, | |
| }, | |
| handleScale: { | |
| mouseWheel: true, | |
| pinch: true, | |
| axisPressedMouseMove: { | |
| price: true, | |
| time: true, | |
| }, | |
| }, | |
| }); | |
| // Candlestick series | |
| this.candleSeries = this.chart.addCandlestickSeries({ | |
| upColor: '#00e5a0', | |
| downColor: '#ff4757', | |
| borderUpColor: '#00e5a0', | |
| borderDownColor: '#ff4757', | |
| wickUpColor: 'rgba(0, 229, 160, 0.6)', | |
| wickDownColor: 'rgba(255, 71, 87, 0.6)', | |
| }); | |
| // Volume series | |
| this.volumeSeries = this.chart.addHistogramSeries({ | |
| priceFormat: { type: 'volume' }, | |
| priceScaleId: 'volume', | |
| }); | |
| this.chart.priceScale('volume').applyOptions({ | |
| scaleMargins: { top: 0.85, bottom: 0 }, | |
| }); | |
| // Handle resize with mobile clientWidth guards | |
| this._resizeObserver = new ResizeObserver(() => { | |
| if (this.container && this.chart) { | |
| let w = this.container.clientWidth; | |
| let h = this.container.clientHeight; | |
| if (w < 100) { | |
| w = this.container.parentElement?.clientWidth || window.innerWidth - 20; | |
| } | |
| if (h < 100) { | |
| h = 380; | |
| } | |
| this.chart.applyOptions({ | |
| width: w, | |
| height: h, | |
| }); | |
| } | |
| }); | |
| this._resizeObserver.observe(this.container); | |
| } | |
| /** | |
| * Set historical candle data | |
| */ | |
| setData(candles, symbol = 'BTCUSDT') { | |
| this.symbol = symbol; | |
| if (!candles || candles.length === 0) return; | |
| // Explicitly align chart size on data load to heal asynchronous mobile layouts | |
| if (this.container && this.chart) { | |
| let w = this.container.clientWidth; | |
| let h = this.container.clientHeight; | |
| if (w < 100) { | |
| w = this.container.parentElement?.clientWidth || window.innerWidth - 20; | |
| } | |
| if (h < 100) { | |
| h = 380; | |
| } | |
| this.chart.applyOptions({ | |
| width: w, | |
| height: h, | |
| }); | |
| } | |
| this.lastClose = candles[candles.length - 1].close; | |
| // Configure exact precision based on asset class | |
| const isForex = ['GBPUSD', 'USDCAD'].includes(symbol); | |
| const precision = isForex ? 5 : 2; | |
| const minMove = isForex ? 0.00001 : 0.01; | |
| // Apply precision formatting to candle series | |
| this.candleSeries.applyOptions({ | |
| priceFormat: { | |
| type: 'price', | |
| precision: precision, | |
| minMove: minMove, | |
| }, | |
| }); | |
| const candleData = candles.map(c => ({ | |
| time: c.time, | |
| open: c.open, | |
| high: c.high, | |
| low: c.low, | |
| close: c.close, | |
| })); | |
| const volumeData = candles.map(c => ({ | |
| time: c.time, | |
| value: c.volume, | |
| color: c.close >= c.open | |
| ? 'rgba(0, 229, 160, 0.2)' | |
| : 'rgba(255, 71, 87, 0.2)', | |
| })); | |
| this.candleSeries.setData(candleData); | |
| this.volumeSeries.setData(volumeData); | |
| // Reset vertical price scale to autoScale, clearing any manual zooms/drags | |
| this.chart.priceScale('right').applyOptions({ autoScale: true }); | |
| // Comfortably view the last 100 candles with a 5-bar right-offset padding | |
| const total = candleData.length; | |
| if (total > 0) { | |
| this.chart.timeScale().setVisibleLogicalRange({ | |
| from: Math.max(0, total - 100), | |
| to: total + 5, | |
| }); | |
| } else { | |
| this.chart.timeScale().fitContent(); | |
| } | |
| } | |
| /** | |
| * Update or add a single candle (real-time) | |
| */ | |
| updateCandle(candle) { | |
| if (!candle) return; | |
| this.lastClose = candle.close; | |
| this.candleSeries.update({ | |
| time: candle.time, | |
| open: candle.open, | |
| high: candle.high, | |
| low: candle.low, | |
| close: candle.close, | |
| }); | |
| this.volumeSeries.update({ | |
| time: candle.time, | |
| value: candle.volume, | |
| color: candle.close >= candle.open | |
| ? 'rgba(0, 229, 160, 0.2)' | |
| : 'rgba(255, 71, 87, 0.2)', | |
| }); | |
| } | |
| /** | |
| * Draw EMA lines | |
| */ | |
| drawEMAs(emaData) { | |
| // Remove old EMA lines | |
| Object.values(this.emaLines).forEach(line => { | |
| this.chart.removeSeries(line); | |
| }); | |
| this.emaLines = {}; | |
| const emaColors = { | |
| 9: '#f59e0b', // amber | |
| 21: '#06b6d4', // cyan | |
| 50: '#3b82f6', // blue | |
| 200: '#8b5cf6', // purple | |
| }; | |
| for (const [period, values] of Object.entries(emaData)) { | |
| if (!values || values.length === 0) continue; | |
| const color = emaColors[period] || '#8b92a5'; | |
| const line = this.chart.addLineSeries({ | |
| color: color, | |
| lineWidth: period === '200' ? 2 : 1, | |
| priceLineVisible: false, | |
| lastValueVisible: false, | |
| crosshairMarkerVisible: false, | |
| lineStyle: period === '200' ? LineStyle.Solid : LineStyle.Solid, | |
| }); | |
| const lineData = values | |
| .filter(v => v.value !== null && !isNaN(v.value)) | |
| .map(v => ({ time: v.time, value: v.value })); | |
| if (lineData.length > 0) { | |
| line.setData(lineData); | |
| } | |
| this.emaLines[period] = line; | |
| } | |
| } | |
| /** | |
| * Draw order blocks as colored rectangles using price lines | |
| */ | |
| drawOrderBlocks(orderBlocks, candles) { | |
| // Clear previous | |
| this._clearDrawnObjects('orderBlocks'); | |
| if (!orderBlocks || orderBlocks.length === 0 || !candles || candles.length === 0) return; | |
| const lastClose = candles[candles.length - 1].close; | |
| const isCrypto = this.symbol === 'BTCUSDT'; | |
| const limitPct = isCrypto ? 0.12 : 0.03; // 12% for crypto, 3% for forex/gold | |
| const maxDiff = lastClose * limitPct; | |
| // Filter OBs close to the current price so they don't squish the price scale | |
| const relevantOBs = orderBlocks.filter(ob => { | |
| if (ob.mitigated) return false; | |
| return Math.abs(ob.top - lastClose) <= maxDiff || Math.abs(ob.bottom - lastClose) <= maxDiff; | |
| }); | |
| for (const ob of relevantOBs) { | |
| const color = ob.type === 'bullish' | |
| ? 'rgba(0, 229, 160, 0.08)' | |
| : 'rgba(255, 71, 87, 0.08)'; | |
| const borderColor = ob.type === 'bullish' | |
| ? 'rgba(0, 229, 160, 0.4)' | |
| : 'rgba(255, 71, 87, 0.4)'; | |
| // Draw top and bottom lines of the OB | |
| const topLine = this.candleSeries.createPriceLine({ | |
| price: ob.top, | |
| color: borderColor, | |
| lineWidth: 1, | |
| lineStyle: LineStyle.Dotted, | |
| axisLabelVisible: false, | |
| }); | |
| const bottomLine = this.candleSeries.createPriceLine({ | |
| price: ob.bottom, | |
| color: borderColor, | |
| lineWidth: 1, | |
| lineStyle: LineStyle.Dotted, | |
| axisLabelVisible: false, | |
| }); | |
| this.drawnObjects.orderBlocks.push(topLine, bottomLine); | |
| } | |
| } | |
| /** | |
| * Draw supply/demand zones | |
| */ | |
| drawZones(zones) { | |
| this._clearDrawnObjects('zones'); | |
| if (!zones || zones.length === 0) return; | |
| const lastClose = this.lastClose || zones[0].top; | |
| const isCrypto = this.symbol === 'BTCUSDT'; | |
| const limitPct = isCrypto ? 0.12 : 0.03; | |
| const maxDiff = lastClose * limitPct; | |
| // Filter S/D zones close to the current price so they don't squish the price scale | |
| const relevantZones = zones.filter(zone => { | |
| if (zone.status === 'broken') return false; | |
| return Math.abs(zone.top - lastClose) <= maxDiff || Math.abs(zone.bottom - lastClose) <= maxDiff; | |
| }); | |
| for (const zone of relevantZones) { | |
| const color = zone.type === 'demand' | |
| ? 'rgba(0, 229, 160, 0.3)' | |
| : 'rgba(255, 71, 87, 0.3)'; | |
| const topLine = this.candleSeries.createPriceLine({ | |
| price: zone.top, | |
| color: color, | |
| lineWidth: 1, | |
| lineStyle: LineStyle.Dashed, | |
| axisLabelVisible: false, | |
| }); | |
| const bottomLine = this.candleSeries.createPriceLine({ | |
| price: zone.bottom, | |
| color: color, | |
| lineWidth: 1, | |
| lineStyle: LineStyle.Dashed, | |
| axisLabelVisible: false, | |
| }); | |
| this.drawnObjects.zones.push(topLine, bottomLine); | |
| } | |
| } | |
| /** | |
| * Draw signal entry, SL, and TP levels | |
| */ | |
| drawSignal(signal) { | |
| this._clearDrawnObjects('signalLines'); | |
| if (!signal) return; | |
| // Entry line | |
| const entryLine = this.candleSeries.createPriceLine({ | |
| price: signal.entry, | |
| color: '#3b82f6', | |
| lineWidth: 2, | |
| lineStyle: LineStyle.Solid, | |
| axisLabelVisible: true, | |
| title: `Entry ${signal.entry.toFixed(signal.symbol === 'XAUUSD' ? 2 : (signal.symbol.includes('USD') && !signal.symbol.includes('BTC') && !signal.symbol.includes('ETH') ? 5 : 2))}`, | |
| }); | |
| // SL line | |
| const slLine = this.candleSeries.createPriceLine({ | |
| price: signal.sl, | |
| color: '#ff4757', | |
| lineWidth: 2, | |
| lineStyle: LineStyle.Dashed, | |
| axisLabelVisible: true, | |
| title: 'SL', | |
| }); | |
| // TP1 line | |
| const tp1Line = this.candleSeries.createPriceLine({ | |
| price: signal.tp1, | |
| color: '#00e5a0', | |
| lineWidth: 1, | |
| lineStyle: LineStyle.Dashed, | |
| axisLabelVisible: true, | |
| title: 'TP1', | |
| }); | |
| // TP2 line | |
| if (signal.tp2) { | |
| const tp2Line = this.candleSeries.createPriceLine({ | |
| price: signal.tp2, | |
| color: '#00e5a0', | |
| lineWidth: 1, | |
| lineStyle: LineStyle.LargeDashed, | |
| axisLabelVisible: true, | |
| title: 'TP2', | |
| }); | |
| this.drawnObjects.signalLines.push(tp2Line); | |
| } | |
| this.drawnObjects.signalLines.push(entryLine, slLine, tp1Line); | |
| } | |
| /** | |
| * Set chart markers (swing highs/lows, BOS/CHoCH) | |
| */ | |
| setMarkers(structureData, candles) { | |
| if (!structureData || !candles || candles.length === 0) return; | |
| const markers = []; | |
| // Prioritize markers so we select the most important ones: CHoCH (3) > BOS (2) > SH/SL (1) | |
| // Swing highs | |
| if (structureData.swingHighs) { | |
| for (const sh of structureData.swingHighs.slice(-15)) { | |
| if (sh.index < candles.length) { | |
| markers.push({ | |
| time: candles[sh.index].time, | |
| position: 'aboveBar', | |
| color: '#ff4757', | |
| shape: 'arrowDown', | |
| text: 'SH', | |
| priority: 1, | |
| }); | |
| } | |
| } | |
| } | |
| // Swing lows | |
| if (structureData.swingLows) { | |
| for (const sl of structureData.swingLows.slice(-15)) { | |
| if (sl.index < candles.length) { | |
| markers.push({ | |
| time: candles[sl.index].time, | |
| position: 'belowBar', | |
| color: '#00e5a0', | |
| shape: 'arrowUp', | |
| text: 'SL', | |
| priority: 1, | |
| }); | |
| } | |
| } | |
| } | |
| // Structure breaks | |
| if (structureData.structureBreaks) { | |
| for (const sb of structureData.structureBreaks.slice(-10)) { | |
| if (sb.index < candles.length) { | |
| const isBullish = sb.direction === 'bullish'; | |
| markers.push({ | |
| time: candles[sb.index].time, | |
| position: isBullish ? 'belowBar' : 'aboveBar', | |
| color: sb.type === 'CHoCH' ? '#f59e0b' : (isBullish ? '#00e5a0' : '#ff4757'), | |
| shape: isBullish ? 'arrowUp' : 'arrowDown', | |
| text: sb.type, | |
| priority: sb.type === 'CHoCH' ? 3 : 2, | |
| }); | |
| } | |
| } | |
| } | |
| // Deduplicate markers: ensure at most one marker exists per timestamp + position combination | |
| // This perfectly prevents overlapping/stacking labels on the chart. | |
| const dedupedMap = new Map(); | |
| for (const marker of markers) { | |
| const key = `${marker.time}_${marker.position}`; | |
| const existing = dedupedMap.get(key); | |
| if (!existing || marker.priority > existing.priority) { | |
| dedupedMap.set(key, marker); | |
| } | |
| } | |
| const finalMarkers = Array.from(dedupedMap.values()); | |
| // Clean up internal priority property so it is not sent to lightweight-charts | |
| for (const m of finalMarkers) { | |
| delete m.priority; | |
| } | |
| // Sort markers chronologically (required by lightweight-charts) | |
| finalMarkers.sort((a, b) => a.time - b.time); | |
| this.candleSeries.setMarkers(finalMarkers); | |
| } | |
| /** | |
| * Clear drawn objects of a specific type | |
| */ | |
| _clearDrawnObjects(type) { | |
| if (this.drawnObjects[type]) { | |
| for (const obj of this.drawnObjects[type]) { | |
| try { | |
| this.candleSeries.removePriceLine(obj); | |
| } catch (e) { /* already removed */ } | |
| } | |
| this.drawnObjects[type] = []; | |
| } | |
| } | |
| /** | |
| * Clear everything | |
| */ | |
| clearAll() { | |
| this._clearDrawnObjects('zones'); | |
| this._clearDrawnObjects('orderBlocks'); | |
| this._clearDrawnObjects('signalLines'); | |
| this.candleSeries.setMarkers([]); | |
| Object.values(this.emaLines).forEach(line => { | |
| try { this.chart.removeSeries(line); } catch(e) {} | |
| }); | |
| this.emaLines = {}; | |
| } | |
| /** | |
| * Destroy the chart | |
| */ | |
| destroy() { | |
| if (this._resizeObserver) { | |
| this._resizeObserver.disconnect(); | |
| } | |
| if (this.chart) { | |
| this.chart.remove(); | |
| } | |
| } | |
| } | |
| export default ChartManager; | |