class ConfluenceChart { constructor() { this.chart = null; this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); this.previewLine = null; this.bars = []; this.levels = []; this.onChartClick = null; this.onChartMove = null; this.tooltip = null; this.chartEl = null; this.clickListener = null; this.selectedLineId = null; this.anchorHandles = []; this.draggingAnchor = null; this.onLineChange = null; this.anchorMoveListener = null; this.anchorUpListener = null; this.contextMenu = null; this.contextCutoff = null; this.contextListener = null; this.onLineEnd = null; } create(el) { this.chartEl = 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: '#27825c', downColor: '#bd4545', borderVisible: true, borderUpColor: '#1d6849', borderDownColor: '#963737', wickUpColor: '#1d6849', wickDownColor: '#963737', }); this.resizeObserver = new ResizeObserver(() => { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); requestAnimationFrame(() => this.renderAnchorHandles()); }); this.resizeObserver.observe(el); this.tooltip = document.createElement('div'); this.tooltip.className = 'chart-tooltip'; el.appendChild(this.tooltip); const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); preview.classList.add('chart-preview'); preview.setAttribute('aria-hidden', 'true'); this.previewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.previewLine.setAttribute('hidden', ''); preview.appendChild(this.previewLine); el.appendChild(preview); const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); handles.classList.add('chart-handles'); handles.setAttribute('aria-hidden', 'true'); for (const anchor of ['start', 'end']) { const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); handle.classList.add('chart-anchor'); handle.dataset.anchor = anchor; handle.setAttribute('r', '6'); handle.setAttribute('hidden', ''); handle.addEventListener('pointerdown', event => this.startAnchorDrag(event, anchor)); handle.addEventListener('click', event => event.stopPropagation()); handles.appendChild(handle); this.anchorHandles.push(handle); } el.appendChild(handles); this.contextMenu = document.createElement('div'); this.contextMenu.className = 'chart-context-menu'; this.contextMenu.hidden = true; const endHere = document.createElement('button'); endHere.type = 'button'; endHere.textContent = 'End trendline here'; endHere.addEventListener('click', event => { event.stopPropagation(); this.endSelectedLineHere(); }); this.contextMenu.addEventListener('click', event => event.stopPropagation()); this.contextMenu.appendChild(endHere); el.appendChild(this.contextMenu); this.anchorMoveListener = event => this.moveAnchor(event); this.anchorUpListener = event => this.finishAnchorDrag(event); window.addEventListener('pointermove', this.anchorMoveListener); window.addEventListener('pointerup', this.anchorUpListener); this.contextListener = event => this.showContextMenu(event); el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { this.hideContextMenu(); if (!this.onChartClick) return; const bounds = el.getBoundingClientRect(); const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; const time = this.chart.timeScale().coordinateToTime(point.x); if (time != null) this.onChartClick({ point, time }); }; el.addEventListener('click', this.clickListener); this.chart.subscribeCrosshairMove(param => { if (!param.point || !param.time) { this.tooltip.hidden = true; return; } this.updateLineTooltip(param); if (this.onChartMove) this.onChartMove(param); }); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles()); } setBars(bars) { this.bars = bars; this.candles.setData(bars.map(this.toCandle)); this.chart.timeScale().setVisibleLogicalRange({ from: Math.max(0, bars.length - 160), to: bars.length + 5, }); requestAnimationFrame(() => this.renderAnchorHandles()); } 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); this.renderAnchorHandles(); } 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: level.color || ConfluenceChart.tfColors[level.tf], lineWidth: level.line_width || (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: level.cutoff_t == null, title: level.label, // Overlays outside the visible price range must not flatten the candles. autoscaleInfoProvider: () => null, }; 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 })); const latestTime = this.bars[this.bars.length - 1]?.t; const latestValue = data[data.length - 1]?.value; if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) { data.push({ time: latestTime, value: latestValue }); } } else { data = this.lineData(level); } entry.series.setData(data); } this.renderAnchorHandles(); } setClickHandler(handler) { this.onChartClick = handler; } setMoveHandler(handler) { this.onChartMove = handler; } setLineChangeHandler(handler) { this.onLineChange = handler; } setLineEndHandler(handler) { this.onLineEnd = handler; } setSelectedLine(id) { this.selectedLineId = id; this.hideContextMenu(); this.renderAnchorHandles(); } setLinePreview(start, end, color, lineWidth) { if (start.t === end.t) return; const x1 = this.chart.timeScale().timeToCoordinate(start.t); const x2 = this.chart.timeScale().timeToCoordinate(end.t); const y1 = this.candles.priceToCoordinate(start.p); const y2 = this.candles.priceToCoordinate(end.p); if ([x1, x2, y1, y2].some(value => value == null)) return; this.previewLine.removeAttribute('hidden'); this.previewLine.setAttribute('x1', x1); this.previewLine.setAttribute('y1', y1); this.previewLine.setAttribute('x2', x2); this.previewLine.setAttribute('y2', y2); this.previewLine.setAttribute('stroke', color); this.previewLine.setAttribute('stroke-width', lineWidth); this.previewLine.setAttribute('stroke-dasharray', '6 4'); } clearLinePreview() { if (this.previewLine) this.previewLine.setAttribute('hidden', ''); } updateLineTooltip(param) { const id = this.hitTest(param); const line = this.levels.find(level => level.id === id); this.tooltip.hidden = !line; if (!line) return; this.tooltip.textContent = `#${line.number} ${line.label}`; this.tooltip.style.left = `${param.point.x + 12}px`; this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`; } 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) { let best = null; for (const level of this.levels.filter(value => value.kind === 'manual')) { const points = this.lineData(level).map(point => ({ x: this.chart.timeScale().timeToCoordinate(point.time), y: this.candles.priceToCoordinate(point.value), })); for (let index = 1; index < points.length; index += 1) { const [start, end] = [points[index - 1], points[index]]; if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue; if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue; const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x); const distance = Math.abs(lineY - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } } return best?.id || null; } lineData(level) { const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); const points = [ { time: level.anchor_t, value: level.anchor_p }, { time: level.last_t, value: secondPrice }, ]; const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t); const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t); const cutoffIndex = level.cutoff_t == null ? -1 : this.bars.findIndex(bar => bar.t >= level.cutoff_t); const targetIndex = cutoffIndex >= 0 ? cutoffIndex : this.bars.length - 1; if (anchorIndex >= 0 && secondIndex > anchorIndex && targetIndex > secondIndex) { const value = level.anchor_p + (secondPrice - level.anchor_p) * (targetIndex - anchorIndex) / (secondIndex - anchorIndex); points.push({ time: this.bars[targetIndex].t, value }); } return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time); } renderAnchorHandles() { const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual'); if (!level || !this.anchorHandles.length) { this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); return; } const points = this.lineData(level).slice(0, 2); points.forEach((point, index) => { const x = this.chart.timeScale().timeToCoordinate(point.time); const y = this.candles.priceToCoordinate(point.value); const handle = this.anchorHandles[index]; if (x == null || y == null) { handle.setAttribute('hidden', ''); return; } handle.removeAttribute('hidden'); handle.setAttribute('cx', x); handle.setAttribute('cy', y); handle.setAttribute('fill', level.color || ConfluenceChart.tfColors[level.tf]); }); } startAnchorDrag(event, anchor) { event.preventDefault(); event.stopPropagation(); this.draggingAnchor = { id: this.selectedLineId, anchor }; } moveAnchor(event) { if (!this.draggingAnchor || !this.chartEl) return; const level = this.levels.find(value => value.id === this.draggingAnchor.id); if (!level || !this.bars.length) return; const bounds = this.chartEl.getBoundingClientRect(); const x = Math.max(0, Math.min(bounds.width, event.clientX - bounds.left)); const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top)); const rawTime = this.chart.timeScale().coordinateToTime(x); const price = this.candles.coordinateToPrice(y); if (rawTime == null || price == null) return; const time = this.bars.reduce((nearest, bar) => Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest ).t; const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); if (this.draggingAnchor.anchor === 'start') { if (time >= level.last_t) return; level.anchor_t = time; level.anchor_p = price; level.slope = (secondPrice - price) / (level.last_t - time); } else { if (time <= level.anchor_t) return; if (level.cutoff_t != null && time >= level.cutoff_t) return; level.last_t = time; level.slope = (price - level.anchor_p) / (time - level.anchor_t); } const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.lineData(level)); this.renderAnchorHandles(); } finishAnchorDrag(event) { if (!this.draggingAnchor) return; event.preventDefault(); const level = this.levels.find(value => value.id === this.draggingAnchor.id); this.draggingAnchor = null; if (level && this.onLineChange) this.onLineChange({ ...level }); } showContextMenu(event) { const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual'); if (!level || !this.bars.length) return; event.preventDefault(); const bounds = this.chartEl.getBoundingClientRect(); const x = event.clientX - bounds.left; const rawTime = this.chart.timeScale().coordinateToTime(x); if (rawTime == null) return; const cutoff = this.bars.reduce((nearest, bar) => Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest ).t; if (cutoff <= level.last_t) { this.hideContextMenu(); return; } this.contextCutoff = cutoff; this.contextMenu.hidden = false; this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`; this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`; } hideContextMenu() { if (this.contextMenu) this.contextMenu.hidden = true; this.contextCutoff = null; } endSelectedLineHere() { const level = this.levels.find(value => value.id === this.selectedLineId); if (!level || this.contextCutoff == null) return; level.cutoff_t = this.contextCutoff; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.lineData(level)); this.hideContextMenu(); if (this.onLineEnd) this.onLineEnd({ ...level }); } 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.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener); 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;