class ConfluenceChart { constructor() { this.chart = null; this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); this.bars = []; this.levels = []; this.onChartClick = null; } create(el) { this.chart = LightweightCharts.createChart(el, { autoSize: true, layout: { background: { color: getComputedStyle(document.documentElement).getPropertyValue('--chart-bg').trim() }, textColor: getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(), }, grid: { vertLines: { color: 'rgba(128,128,128,.10)' }, horzLines: { color: 'rgba(128,128,128,.10)' }, }, timeScale: { timeVisible: true, secondsVisible: false }, rightPriceScale: { borderVisible: false }, }); this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, { upColor: '#3fb984', downColor: '#e45757', borderVisible: false, wickUpColor: '#3fb984', wickDownColor: '#e45757', }); this.resizeObserver = new ResizeObserver(() => { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); }); this.resizeObserver.observe(el); this.chart.subscribeClick(param => { if (param.point && param.time && this.onChartClick) this.onChartClick(param); }); } setBars(bars) { this.bars = bars; this.candles.setData(bars.map(this.toCandle)); this.chart.timeScale().fitContent(); } updateBar(bar) { this.candles.update(this.toCandle(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar); } syncLevels(levels) { this.levels = levels; const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id)); for (const [id, entry] of this.levelSeries) { if (!wanted.has(id)) { this.chart.removeSeries(entry.series); this.levelSeries.delete(id); } } for (const level of levels) { if (level.hidden) continue; let entry = this.levelSeries.get(level.id); const isMa = level.kind === 'ma'; const options = { color: ConfluenceChart.tfColors[level.tf], lineWidth: level.tf === '1d' ? 2 : 1, lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, lastValueVisible: true, title: level.label, }; if (!entry) { entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, options) }; this.levelSeries.set(level.id, entry); } else { entry.series.applyOptions(options); } let data; if (isMa) { data = (level.points || []).map(([time, value]) => ({ time, value })); } else { const first = this.bars[0]?.t || level.anchor_t; const last = this.bars[this.bars.length - 1]?.t || level.last_t; const extension = Math.max(60, Math.floor((last - first) * 0.2)); const end = Math.max(last, level.last_t) + extension; data = [ { time: level.anchor_t, value: level.anchor_p }, { time: end, value: level.anchor_p + level.slope * (end - level.anchor_t) }, ]; } entry.series.setData(data); } } setClickHandler(handler) { this.onChartClick = handler; } pointFromClick(param, snap) { const rawPrice = this.candles.coordinateToPrice(param.point.y); let point = { t: Number(param.time), p: rawPrice, snappedSide: null }; if (!snap || !this.bars.length) return point; const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? bar : best); const candidates = [ { p: nearest.h, side: 'resistance' }, { p: nearest.l, side: 'support' }, ]; const snapped = candidates .map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - param.point.y) })) .sort((a, b) => a.distance - b.distance)[0]; if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; return point; } hitTest(param) { const t = Number(param.time); let best = null; for (const level of this.levels.filter(value => value.kind === 'manual')) { const price = level.anchor_p + level.slope * (t - level.anchor_t); const coordinate = this.candles.priceToCoordinate(price); const distance = Math.abs(coordinate - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } return best?.id || null; } toCandle(bar) { return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c }; } destroy() { if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.chart) this.chart.remove(); } } ConfluenceChart.tfColors = { '1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b', '30m':'#a8c85d', '1h':'#efb643', '4h':'#ec7b42', '1d':'#d96073', }; window.ConfluenceChart = ConfluenceChart;