class ConfluenceChart { constructor() { this.chart = null; this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); this.priceLines = new Map(); this.previewLine = null; this.bars = []; this.levels = []; this.onChartClick = 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; // Tool arming: the sidebar decides what the next chart gesture creates. this.armedTool = null; this.gesture = null; this.pendingAnchor = null; this.onToolComplete = null; this.snapToBars = true; this.priceTag = null; this.toolDownListener = null; this.toolMoveListener = null; this.toolUpListener = null; this.pendingView = null; } static TICK = 0.25; static snapToTick(price) { return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK; } 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, // The library is timezone-agnostic: it reads epoch seconds as UTC and // labels them as UTC. Bars stay in UTC — that convention holds // everywhere in this codebase, and shifting the values themselves would // put every trendline anchor, hit test and alert an offset out. Only // the label is localised, via the browser's own zone. tickMarkFormatter: (time, tickMarkType) => { const at = new Date(time * 1000); if (tickMarkType === LightweightCharts.TickMarkType.Year) return String(at.getFullYear()); if (tickMarkType === LightweightCharts.TickMarkType.Month) { return at.toLocaleString(undefined, { month: 'short' }); } if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) return String(at.getDate()); return at.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', ...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds ? { second: '2-digit' } : {}), }); }, }, localization: { // The crosshair readout, which would otherwise disagree with the axis. timeFormatter: time => new Date(time * 1000).toLocaleString(undefined, { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }), }, rightPriceScale: { borderVisible: false }, // Daily context lives on the left, intraday on the right. leftPriceScale: { visible: true, borderVisible: false }, }); this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, { upColor: '#27825c', downColor: '#bd4545', borderVisible: true, borderUpColor: '#1d6849', borderDownColor: '#963737', wickUpColor: '#1d6849', wickDownColor: '#963737', }); // A price scale derives its range from the series on it, so levels moved to // the left would be drawn against a different range and sit at the wrong // height. This transparent copy of the candles gives the left scale exactly // the same input as the right, which keeps one price at one y. this.leftMirror = this.chart.addSeries(LightweightCharts.CandlestickSeries, { priceScaleId: 'left', upColor: 'transparent', downColor: 'transparent', borderVisible: false, wickUpColor: 'transparent', wickDownColor: 'transparent', lastValueVisible: false, priceLineVisible: false, }); 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.priceTag = document.createElement('div'); this.priceTag.className = 'chart-price-tag'; this.priceTag.hidden = true; el.appendChild(this.priceTag); this.toolDownListener = event => this.startToolGesture(event); this.toolMoveListener = event => this.moveToolGesture(event); this.toolUpListener = event => this.finishToolGesture(event); el.addEventListener('pointerdown', this.toolDownListener); window.addEventListener('pointermove', this.toolMoveListener); window.addEventListener('pointerup', this.toolUpListener); 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(); // A placement gesture ends in a click too; selecting on it would pick // whatever the new line happens to overlap. if (this.armedTool) return; 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); }); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles()); } setBars(bars) { this.bars = bars; const candleData = bars.map(this.toCandle); this.candles.setData(candleData); this.leftMirror.setData(candleData); // Anchored by time, not by logical index. A logical index addresses the // chart's *shared* scale — the union of every series' time points — not // this array. The daily MAs land straight after with hundreds of points // pre-dating the 1m window, and prepending them shifts every logical index // by that count, silently dragging the view ten hours off the live edge. // A time range names the instant, so later series cannot move it. if (bars.length) { const last = bars[bars.length - 1]; // Keep the old five bars of right-hand breathing room, in seconds. const step = bars.length > 1 ? last.t - bars[bars.length - 2].t : 60; this.pendingView = { from: bars[Math.max(0, bars.length - 160)].t, to: last.t + step * 5, }; this.chart.timeScale().setVisibleRange(this.pendingView); } requestAnimationFrame(() => this.renderAnchorHandles()); } updateBar(bar) { // update() throws on anything older than the series' last point, which // takes the whole app down rather than dropping one stale bar. Ticks made // this reachable often enough to matter, so refuse it here as well as at // the source: a bar behind the last one has nothing to contribute. const last = this.bars[this.bars.length - 1]; if (last && bar.t < last.t) return; this.candles.update(this.toCandle(bar)); this.leftMirror.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(); } // A typed price alert is a manual line with zero slope. Treating it as flat // here is what makes it render as a level rather than a stubby segment. static isFlat(level) { return level.kind === 'horizontal' || (level.kind === 'manual' && level.slope === 0); } // Flat levels are drawn as price lines rather than two-point series: they // span the whole chart regardless of scroll and get a price-axis label. syncPriceLines(levels) { const flat = levels.filter(level => ConfluenceChart.isFlat(level) && !level.hidden); const wanted = new Set(flat.map(level => level.id)); for (const [id, entry] of this.priceLines) { if (!wanted.has(id)) { entry.host.removePriceLine(entry.line); this.priceLines.delete(id); } } for (const level of flat) { const options = { price: level.anchor_p, color: ConfluenceChart.levelColor(level), lineWidth: level.line_width || 1, lineStyle: LightweightCharts.LineStyle.Dashed, axisLabelVisible: true, title: level.label, }; const existing = this.priceLines.get(level.id); if (existing) existing.line.applyOptions(options); else { // Prior-day levels are daily context and move to the left scale; a // hand-drawn price level is intraday and keeps the right, which is the // side being kept clear for it. const host = level.kind === 'horizontal' ? this.leftMirror : this.candles; this.priceLines.set(level.id, { host, line: host.createPriceLine(options) }); } } } // Level points are sampled on their own timeframe — VWAP every minute, the // daily averages once a session — and every distinct timestamp claims its own // slot on the chart's shared scale. Left raw, a minute-resolution VWAP spread // 160 hourly candles across 908 slots and drew them as unreadable slivers. // Snapping onto the candle grid preserves the line's shape while keeping the // scale one slot per candle, which is what makes the bars their proper width. snapPointsToBars(points) { if (!this.bars.length || !points.length) return points; const times = this.bars.map(bar => bar.t); const last = times[times.length - 1]; const byTime = new Map(); for (const point of points) { // Clamped, not passed through: on a daily chart every one of VWAP's ~760 // minute points falls after the last candle's session open, and letting // them keep their own times put all 760 back on the scale. Projection to // the right of the last bar is handled by the caller instead. if (point.time >= last) { byTime.set(last, point.value); continue; } let lo = 0; let hi = times.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (times[mid] < point.time) lo = mid + 1; else hi = mid; } // Points older than the window collapse onto the first candle; the newest // of them wins, which is the value in force when the window opens. byTime.set(times[lo], point.value); } return [...byTime.entries()] .sort((a, b) => a[0] - b[0]) .map(([time, value]) => ({ time, value })); } syncLevels(levels) { this.levels = levels; this.syncPriceLines(levels); const drawn = levels.filter(level => !ConfluenceChart.isFlat(level)); const wanted = new Set(drawn.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 drawn) { if (level.hidden) continue; let entry = this.levelSeries.get(level.id); // Both trace a series of points, but a higher-timeframe average holds its // value between closes while VWAP moves continuously. const hasPoints = level.kind === 'ma' || level.kind === 'vwap'; const isMa = level.kind === 'ma'; const options = { color: ConfluenceChart.levelColor(level), 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) { // Daily averages and session VWAP are daily/session context, so their // last-value labels belong on the left. priceScaleId is fixed at // creation, which is why it is not in the options reapplied below. const scale = (isMa || level.kind === 'vwap') ? 'left' : 'right'; entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, { ...options, priceScaleId: scale }), }; this.levelSeries.set(level.id, entry); } else { entry.series.applyOptions(options); } let data; if (hasPoints) { data = this.snapPointsToBars((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); } // Re-anchor once, after the level series have reshaped the scale. setBars // runs before them, so the width it asked for was derived from the previous // timeframe's point density and the chart holds that width as new series // arrive. Consumed rather than reapplied every time: VWAP resyncs a level // every minute, and re-anchoring on each would yank the view back from // wherever the user had panned it. if (this.pendingView) { this.chart.timeScale().setVisibleRange(this.pendingView); this.pendingView = null; } this.renderAnchorHandles(); } // --- tool arming and placement gestures --------------------------------- setToolCompleteHandler(handler) { this.onToolComplete = handler; } setSnap(enabled) { this.snapToBars = enabled; } /** * Arm a tool so the next chart gesture places one. Panning is suspended * while armed, otherwise the drag that draws a line also drags the chart out * from under it. */ armTool(tool) { this.armedTool = tool; this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool }); this.chartEl.classList.toggle('armed', Boolean(tool)); this.pendingAnchor = null; this.clearGesture(); this.clearLinePreview(); } eventPoint(event) { const bounds = this.chartEl.getBoundingClientRect(); const x = event.clientX - bounds.left; const y = event.clientY - bounds.top; const price = this.candles.coordinateToPrice(y); if (price == null) return null; const time = this.chart.timeScale().coordinateToTime(x); return { x, y, p: price, t: time == null ? null : Number(time) }; } startToolGesture(event) { if (!this.armedTool || event.button !== 0) return; const point = this.eventPoint(event); if (!point) return; event.preventDefault(); this.gesture = { start: point, end: point }; this.chartEl.setPointerCapture?.(event.pointerId); this.renderGesture(); } moveToolGesture(event) { if (!this.armedTool) return; const point = this.eventPoint(event); if (!point) return; if (this.gesture) { this.gesture.end = point; this.renderGesture(); return; } // Waiting on the second click: rubber-band from the placed anchor. if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point)); } renderPending(a, b) { if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); } finishToolGesture(event) { if (!this.armedTool || !this.gesture) return; const tool = this.armedTool; const { start, end } = this.gesture; this.clearGesture(); this.chartEl.releasePointerCapture?.(event.pointerId); if (tool === 'level') { // A click with no drag is a valid placement; the drag is only there to // let you fine-tune the price before committing. this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) }); return; } // Both idioms are supported. Press-drag-release places a trendline in one // gesture; a plain click sets the first anchor and the next click finishes // it. Rejecting short gestures outright left the tool armed and silent, so // the next click began a whole new line — which read as the line suddenly // continuing at a different slope. const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5; const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end)); const b = this.snapPoint(end); if (!dragged && !this.pendingAnchor) { this.pendingAnchor = b; this.renderPending(b, b); return; } this.pendingAnchor = null; this.clearLinePreview(); if (a.t == null || b.t == null || a.t === b.t) return; this.onToolComplete?.({ tool, start: a, end: b }); } /** Trendline anchors snap to bar extremes; levels snap to the tick grid. */ snapPoint(point) { const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null; const base = { t: fallbackT, p: point.p, snappedSide: null }; if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; const nearest = this.bars.reduce( (best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? 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) - point.y) })) .sort((a, b) => a.distance - b.distance)[0]; if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; return base; } renderGesture() { if (!this.gesture) return; const { start, end } = this.gesture; if (this.armedTool === 'level') { const price = ConfluenceChart.snapToTick(end.p); const y = this.candles.priceToCoordinate(price); if (y == null) return; this.previewLine.removeAttribute('hidden'); this.previewLine.setAttribute('x1', 0); this.previewLine.setAttribute('y1', y); this.previewLine.setAttribute('x2', this.chartEl.clientWidth); this.previewLine.setAttribute('y2', y); this.previewLine.setAttribute('stroke', '#e0a34a'); this.previewLine.setAttribute('stroke-width', 2); this.previewLine.setAttribute('stroke-dasharray', '6 4'); this.priceTag.hidden = false; this.priceTag.textContent = price.toFixed(2); this.priceTag.style.top = `${y}px`; this.priceTag.style.left = `${Math.min(end.x + 14, this.chartEl.clientWidth - 70)}px`; return; } const a = this.snapPoint(start); const b = this.snapPoint(end); if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); } clearGesture() { this.gesture = null; if (this.priceTag) this.priceTag.hidden = true; // The pending anchor's rubber band must survive between the two clicks. if (!this.pendingAnchor) this.clearLinePreview(); } setClickHandler(handler) { this.onChartClick = 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`; } hitTest(param) { let best = null; // Flat levels draw as price lines, which have no series geometry to hit — // they are selected and removed from the sidebar list instead. for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { 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; } /** * Fractional index of a timestamp in the bar series. Mirrors index_at() in * app/analysis/bar_space.py — the chart spaces bars evenly however much time * separates them, so this is the space a straight line is straight in. */ indexAt(time) { const bars = this.bars; if (bars.length < 2) return 0; if (time <= bars[0].t) { const step = bars[1].t - bars[0].t; return step ? (time - bars[0].t) / step : 0; } const last = bars.length - 1; if (time >= bars[last].t) { const step = bars[last].t - bars[last - 1].t; return last + (step ? (time - bars[last].t) / step : 0); } let lo = 0; let hi = last; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (bars[mid].t <= time) lo = mid; else hi = mid; } const span = bars[hi].t - bars[lo].t; return lo + (span ? (time - bars[lo].t) / span : 0); } lineData(level) { // Interpolated across bars, matching what the server scores the level at. // Doing it per second instead kicked the line upward at every session gap, // because a one-hour halt is one bar wide but an hour of slope. const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); const times = [level.anchor_t, level.last_t]; const cutoffIndex = level.cutoff_t == null ? -1 : this.bars.findIndex(bar => bar.t >= level.cutoff_t); const target = cutoffIndex >= 0 ? this.bars[cutoffIndex] : this.bars[this.bars.length - 1]; if (target && target.t > level.last_t) times.push(target.t); const startIndex = this.indexAt(level.anchor_t); const span = this.indexAt(level.last_t) - startIndex; return times .filter((time, index) => index === 0 || time !== times[index - 1]) .map(time => ({ time, value: span ? level.anchor_p + (endPrice - level.anchor_p) * (this.indexAt(time) - startIndex) / span : level.anchor_p, })); } renderAnchorHandles() { const level = this.levels.find( value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value), ); 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', ConfluenceChart.levelColor(level)); }); } 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' && !ConfluenceChart.isFlat(value), ); 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.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener); if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener); if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener); if (this.chart) this.chart.remove(); } } ConfluenceChart.tfColors = { '1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b', '30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073', }; // VWAP and the prior-day levels are both stamped 1d, so without their own // colours they would be indistinguishable from the daily moving averages. ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' }; ConfluenceChart.levelColor = level => level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf]; window.ConfluenceChart = ConfluenceChart;