// Vertical session lines via Lightweight Charts' series primitive — canvas, // not a DOM overlay. createPriceLine is horizontal only; attachPrimitive is // the matching hook for a time. class RthLinesPrimitive { constructor() { this._times = []; this._styles = []; this._xs = []; this._chart = null; this._requestUpdate = null; this._view = { update: () => this._sync(), renderer: () => this._renderer, }; this._renderer = { draw: target => this._draw(target) }; } attached(param) { this._chart = param.chart; this._requestUpdate = param.requestUpdate; } detached() { this._chart = null; this._requestUpdate = null; } setMarks(marks) { this._times = marks.map(mark => mark.t); this._styles = marks.map(mark => mark.kind); if (this._requestUpdate) this._requestUpdate(); } updateAllViews() { this._view.update(); } paneViews() { return [this._view]; } _sync() { if (!this._chart) { this._xs = []; return; } const scale = this._chart.timeScale(); this._xs = this._times.map(time => scale.timeToCoordinate(time)); } _draw(target) { if (!this._xs.length) return; target.useBitmapCoordinateSpace(scope => { const ctx = scope.context; const height = scope.bitmapSize.height; const width = Math.max(1, Math.round(scope.horizontalPixelRatio)); ctx.save(); for (let i = 0; i < this._xs.length; i += 1) { const x = this._xs[i]; if (x == null) continue; const px = Math.round(x * scope.horizontalPixelRatio); ctx.strokeStyle = 'rgba(44,41,36,0.20)'; ctx.lineWidth = width; ctx.setLineDash(this._styles[i] === 'close' ? [3 * width, 4 * width] : []); ctx.beginPath(); ctx.moveTo(px + 0.5, 0); ctx.lineTo(px + 0.5, height); ctx.stroke(); } ctx.restore(); }); } } class FibLevelsPrimitive { constructor(locateTime) { this._locate = locateTime; this._fibs = []; this._series = null; this._requestUpdate = null; this._items = []; this._view = { update: () => this._sync(), renderer: () => this._renderer, }; this._renderer = { draw: target => this._draw(target) }; } attached(param) { this._series = param.series; this._requestUpdate = param.requestUpdate; } detached() { this._series = null; this._requestUpdate = null; } setFibs(fibs) { this._fibs = fibs || []; if (this._requestUpdate) this._requestUpdate(); } updateAllViews() { this._view.update(); } paneViews() { return [this._view]; } _sync() { const items = []; if (!this._series) { this._items = []; return; } for (const fib of this._fibs) { if (fib.hidden) continue; const x0 = this._locate(fib.anchor_t); const y0 = this._series.priceToCoordinate(Number(fib.anchor_p)); const endP = Number(fib.anchor_p) + Number(fib.slope || 0) * (Number(fib.last_t) - Number(fib.anchor_t)); const y1 = this._series.priceToCoordinate(endP); const width = Math.max(1, Number(fib.line_width) || 2); const color = fib.color || '#c8992f'; if (x0 != null && y0 != null && y1 != null) { items.push({ kind: 'rail', x: x0, y0, y1, color, width, }); } for (const level of ConfluenceChart.fibPrices(fib)) { const y = this._series.priceToCoordinate(level.price); if (y == null) continue; const major = level.r === 0 || level.r === 0.5 || level.r === 0.618 || level.r === 1; items.push({ kind: 'level', x0, y, color, width, dashed: !major, label: `${level.label} ${level.price.toFixed(2)}`, }); } } this._items = items; } _draw(target) { if (!this._items.length) return; target.useMediaCoordinateSpace(scope => { const ctx = scope.context; const right = scope.mediaSize.width; ctx.save(); ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace'; for (const item of this._items) { if (item.kind === 'rail') { ctx.strokeStyle = item.color; ctx.lineWidth = item.width; ctx.setLineDash([]); ctx.globalAlpha = 0.85; ctx.beginPath(); ctx.moveTo(item.x + 0.5, item.y0); ctx.lineTo(item.x + 0.5, item.y1); ctx.stroke(); continue; } const start = item.x0 == null ? 0 : Math.max(0, item.x0); if (start > right) continue; ctx.strokeStyle = item.color; ctx.fillStyle = item.color; ctx.lineWidth = item.width; ctx.setLineDash(item.dashed ? [5, 4] : []); ctx.globalAlpha = 0.9; ctx.beginPath(); ctx.moveTo(start, item.y + 0.5); ctx.lineTo(right, item.y + 0.5); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.fillText(item.label, right - ctx.measureText(item.label).width - 6, item.y - 4); } ctx.restore(); }); } } class ConfluenceChart { constructor() { this.chart = null; this.candles = null; this.resizeObserver = null; this.plotResizeObserver = null; this.observedPlotCanvas = null; this.levelSeries = new Map(); this.priceLines = new Map(); this.currentPriceLine = null; this.currentPricePulse = null; this.projectionDiagnostic = null; this.projectionDiagnosticFrame = null; this.animateCurrentPrice = true; this.autoScrollLivePrice = true; this.lastCurrentPrice = null; this.previewLine = null; this.lineBridgeLayer = null; this.bars = []; this.levels = []; this.trendlineGeometryMode = 'legacy'; this.trendlineSourceSeries = new Map(); this.onChartClick = null; this.tooltip = null; this.chartEl = null; this.clickListener = null; this.selectedLineId = null; this.selectedDrawingId = null; this.anchorHandles = []; this.lineHitTarget = null; this.lineFocus = null; this.lineFocusTimer = null; this.draggingLine = null; this.lineMoveListener = null; this.lineUpListener = null; this.draggingAnchor = null; this.onLineChange = null; this.anchorMoveListener = null; this.anchorUpListener = null; this.contextMenu = null; this.contextCutoff = null; this.contextLineId = null; this.contextEndButton = null; this.contextExtendButton = null; this.contextListener = null; this.onLineEnd = null; this.onLineDuplicate = null; this.onLineDelete = 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; this.overlayLayer = null; this.rthPrimitive = null; this.rthEnabled = true; this.fibs = []; this.fibPrimitive = null; this.fibPreview = null; this.futureSpace = null; this.snapDot = null; this.snapLabel = null; this.snapLeader = null; this.snapLeaderLine = null; this.comments = []; this.commentNodes = new Map(); this.commentLayer = null; this.contextLabelLayer = null; this.overlayFrame = null; this.overlayForce = false; this.overlayRangeKey = ''; this.pointerAttached = { tool: false, anchor: false, line: false }; this.onCommentToggle = null; this.onCommentMove = null; this.onCommentSelect = null; this.describeDrawingColor = null; this.lastSnapDiagnostic = null; this.manualPriceRange = null; this.pricePinch = null; this.touchStartListener = null; this.touchMoveListener = null; this.touchEndListener = null; this.priceResetListener = null; this.touchTap = null; this.suppressClickUntil = 0; this.candleAutoscaleProvider = original => { const info = original(); if (!info || !this.manualPriceRange) return info; return { ...info, priceRange: { ...this.manualPriceRange } }; }; } static TICK = 0.25; // Pixels of travel between press and release that make a gesture a drag // rather than a click. Wide enough to survive a twitch on a deliberate click. static DRAG_THRESHOLD = 12; // How far past the last bar the axis is extended. Enough to date a // convergence a few sessions out without dominating a zoomed-out view. static FUTURE_SLOTS = 180; // Preserve short intraday gaps as empty slots. Longer closures remain // compressed, including the 65-minute CME settlement break and weekends. static MAX_INTRADAY_GAP_SECONDS = 30 * 60; /** * The timeframe's spacing, measured rather than assumed. * * Taking the gap between the final two bars looks equivalent and is not: the * newest bar is often partial or irregularly stamped — a live one arrived at * :02 past the minute — which made that gap two seconds on a one-minute * chart. Everything derived from it was then wrong by a factor of thirty. A * median over recent bars ignores both the ragged tail and session gaps. */ static barInterval(bars) { const deltas = []; for (let i = Math.max(1, bars.length - 30); i < bars.length; i += 1) { const delta = bars[i].t - bars[i - 1].t; if (delta > 0) deltas.push(delta); } if (!deltas.length) return 0; deltas.sort((a, b) => a - b); return deltas[Math.floor(deltas.length / 2)]; } /** * Whether to report snap geometry to the server. * * `?diag=1` turns it on and remembers it; `?diag=0` turns it off. Kept out of * the way rather than always on: it exists because the browser runs on a * different machine from whoever is debugging it. */ static diagnosticMode() { const asked = new URLSearchParams(location.search).get('diag'); if (asked === '1') localStorage.setItem('chart-diag', '1'); if (asked === '0') localStorage.removeItem('chart-diag'); return localStorage.getItem('chart-diag') === '1'; } 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(), attributionLogo: false, }, 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); const daily = this.bars[0]?.tf === '1d'; if (tickMarkType === LightweightCharts.TickMarkType.Year) { return String(daily ? at.getUTCFullYear() : at.getFullYear()); } if (tickMarkType === LightweightCharts.TickMarkType.Month) { return at.toLocaleString(undefined, { month: 'short', ...(daily ? { timeZone: 'UTC' } : {}) }); } if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) { return String(daily ? at.getUTCDate() : 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 => { const daily = this.bars[0]?.tf === '1d'; return new Date(time * 1000).toLocaleString(undefined, daily ? { timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' } : { weekday: 'short', hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }, }, rightPriceScale: { borderVisible: false }, handleScale: { mouseWheel: true, pinch: false, axisPressedMouseMove: { time: true, price: true }, axisDoubleClickReset: { time: true, price: true }, }, handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: false, vertTouchDrag: false, }, // Every price-bearing series shares this scale. A second autoscaled price // axis can place the same numeric price at a different y, which makes // daily context look comparable to intraday price when it is not. leftPriceScale: { visible: false }, // Never magnet. The default snaps the crosshair to the bar's *close*, so // hovering beside a low reads a price several ticks away, and everything // that snaps in this app snaps to extremes. The crosshair tracks the // cursor; the snap dot says where an anchor would actually land. crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, }); this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, { upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true, borderUpColor: '#087f76', borderDownColor: '#b9342d', wickUpColor: '#087f76', wickDownColor: '#b9342d', autoscaleInfoProvider: this.candleAutoscaleProvider, lastValueVisible: false, priceLineVisible: false, }); // Volume as an overlay on its own hidden scale, confined to the bottom // fifth. An overlay rather than a pane so it cannot alter the price scale: // volumes are five figures and prices four, and sharing a scale would // flatten the candles into a line. this.volume = this.chart.addSeries(LightweightCharts.HistogramSeries, { priceScaleId: 'volume', priceFormat: { type: 'volume' }, lastValueVisible: false, priceLineVisible: false, }); this.chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.88, bottom: 0 }, visible: false, }); // The default leaves a tenth of the pane empty beneath the lowest bar, // which is where the volume draws and where a cursor tracing swing lows // naturally sits — far from any actual price. Give most of it back. this.chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } }); // Whitespace to the right of the last bar. Lightweight Charts only labels // times that exist on the scale, so the axis under the projection area was // blank — you could watch two trendlines converge and not know when. These // points carry no value and draw nothing; they exist so the axis has // something to label and so timeToCoordinate answers out there. this.rthPrimitive = new RthLinesPrimitive(); this.candles.attachPrimitive(this.rthPrimitive); this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time)); this.candles.attachPrimitive(this.fibPrimitive); this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, { priceScaleId: 'right', color: 'rgba(0,0,0,0)', lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: false, autoscaleInfoProvider: () => null, }); this.resizeObserver = new ResizeObserver(() => { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); requestAnimationFrame(() => { this.observePlotCanvas(); this.scheduleOverlays(true); }); }); this.resizeObserver.observe(el); // Every overlay lives in here, and this is positioned over the *plot* — not // the element, which also contains the price scales. Lightweight Charts // reports coordinates from the plot's origin, so anchoring the container // there lets each overlay use those coordinates untranslated. Keep the // measurement even with no left scale: future layout changes must not // silently displace snap indicators, comments or trendline handles. this.overlayLayer = document.createElement('div'); this.overlayLayer.className = 'chart-overlays'; el.appendChild(this.overlayLayer); this.contextLabelLayer = document.createElement('div'); this.contextLabelLayer.className = 'chart-context-labels'; this.overlayLayer.appendChild(this.contextLabelLayer); this.tooltip = document.createElement('div'); this.tooltip.className = 'chart-tooltip'; this.overlayLayer.appendChild(this.tooltip); this.ohlcReadout = document.createElement('div'); this.ohlcReadout.className = 'chart-ohlc'; this.ohlcReadout.hidden = true; this.overlayLayer.appendChild(this.ohlcReadout); this.currentPricePulse = document.createElement('div'); this.currentPricePulse.className = 'current-price-pulse'; this.currentPricePulse.hidden = true; this.overlayLayer.appendChild(this.currentPricePulse); this.projectionDiagnostic = document.createElement('pre'); this.projectionDiagnostic.className = 'projection-diagnostic'; this.projectionDiagnostic.hidden = true; this.overlayLayer.appendChild(this.projectionDiagnostic); 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); this.fibPreview = document.createElementNS('http://www.w3.org/2000/svg', 'g'); this.fibPreview.setAttribute('hidden', ''); preview.appendChild(this.fibPreview); this.overlayLayer.appendChild(preview); this.lineBridgeLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.lineBridgeLayer.classList.add('chart-line-bridges'); this.lineBridgeLayer.setAttribute('aria-hidden', 'true'); this.overlayLayer.appendChild(this.lineBridgeLayer); const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); handles.classList.add('chart-handles'); handles.setAttribute('aria-hidden', 'true'); this.lineFocus = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.lineFocus.classList.add('chart-line-focus'); this.lineFocus.setAttribute('hidden', ''); handles.appendChild(this.lineFocus); this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); this.lineHitTarget.classList.add('chart-line-hit'); this.lineHitTarget.setAttribute('hidden', ''); this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event)); this.lineHitTarget.addEventListener('click', event => event.stopPropagation()); handles.appendChild(this.lineHitTarget); 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); } this.overlayLayer.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.contextEndButton = endHere; const extend = document.createElement('button'); extend.type = 'button'; extend.textContent = 'Extend trendline'; extend.addEventListener('click', event => { event.stopPropagation(); this.extendSelectedLine(); }); this.contextExtendButton = extend; const duplicate = document.createElement('button'); duplicate.type = 'button'; duplicate.dataset.action = 'duplicate'; duplicate.textContent = 'Duplicate'; duplicate.addEventListener('click', event => { event.stopPropagation(); const level = this.levels.find(value => value.id === this.contextLineId); this.hideContextMenu(); if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level }); }); const deleteLine = document.createElement('button'); deleteLine.type = 'button'; deleteLine.dataset.action = 'delete'; deleteLine.textContent = 'Delete'; deleteLine.addEventListener('click', event => { event.stopPropagation(); const id = this.contextLineId; this.hideContextMenu(); if (id && this.onLineDelete) this.onLineDelete(id); }); this.contextMenu.addEventListener('click', event => event.stopPropagation()); this.contextMenu.append(endHere, extend, duplicate, deleteLine); this.overlayLayer.appendChild(this.contextMenu); this.priceTag = document.createElement('div'); this.priceTag.className = 'chart-price-tag'; this.priceTag.hidden = true; this.overlayLayer.appendChild(this.priceTag); this.snapDot = document.createElement('div'); this.snapDot.className = 'chart-snap-dot'; this.snapDot.hidden = true; this.overlayLayer.appendChild(this.snapDot); // States the price the anchor will use. Useful in itself, and it means a // single screenshot answers "is the dot in the wrong place, or on the // wrong bar?" without anyone pasting console output. this.snapLabel = document.createElement('div'); this.snapLabel.className = 'chart-snap-label'; this.snapLabel.hidden = true; this.overlayLayer.appendChild(this.snapLabel); // A leader from the cursor to the target. The snap is often far from the // pointer — hovering below the candles legitimately snaps up to a bar's low // — and without a line joining them the dot reads as unrelated to where you // are pointing. this.snapLeader = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.snapLeader.classList.add('chart-snap-leader'); this.snapLeader.setAttribute('aria-hidden', 'true'); this.snapLeaderLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.snapLeader.appendChild(this.snapLeaderLine); this.overlayLayer.appendChild(this.snapLeader); this.commentLayer = document.createElement('div'); this.commentLayer.className = 'chart-comments'; this.overlayLayer.appendChild(this.commentLayer); this.toolDownListener = event => this.startToolGesture(event); this.toolMoveListener = event => this.moveToolGesture(event); this.toolUpListener = event => this.finishToolGesture(event); el.addEventListener('pointerdown', this.toolDownListener); this.anchorMoveListener = event => this.moveAnchor(event); this.anchorUpListener = event => this.finishAnchorDrag(event); this.lineMoveListener = event => this.moveLine(event); this.lineUpListener = event => this.finishLineDrag(event); this.contextListener = event => this.showContextMenu(event); el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { this.hideContextMenu(); if (Date.now() < this.suppressClickUntil) return; // 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 point = this.eventPoint(event); if (point && this.withinPlot(point) && point.t != null) { this.onChartClick({ point, time: point.t }); } }; el.addEventListener('click', this.clickListener); this.chart.subscribeCrosshairMove(param => { if (!param.point || !param.time) { this.tooltip.hidden = true; this.hideOhlc(); return; } this.updateOhlc(param.time); this.updateLineTooltip(param); }); requestAnimationFrame(() => this.observePlotCanvas()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.scheduleOverlays(); }); this.installPinchZoom(); } setBars(bars) { if (this.bars[0]?.tf && bars[0]?.tf && this.bars[0].tf !== bars[0].tf) { this.manualPriceRange = null; } this.bars = bars; const candleData = bars.map(this.toCandle); this.candles.setData(candleData); this.updateCurrentPrice(bars[bars.length - 1]?.c); this.volume.setData(bars.map(ConfluenceChart.toVolume)); this.futureSpace.setData(this.timeScaleSlots(bars)); // 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]; // Five bars of right-hand breathing room, in seconds. const step = ConfluenceChart.barInterval(bars) || 60; this.pendingView = { from: bars[Math.max(0, bars.length - 160)].t, to: last.t + step * 5, }; this.chart.timeScale().setVisibleRange(this.pendingView); } this.syncRthLines(); this.scheduleOverlays(true); } setTrendlineGeometry(geometry) { this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy'; this.trendlineSourceSeries = new Map( Object.entries(geometry?.series || {}).map(([tf, value]) => { const times = (value.times || []).map(Number); const futureTimes = (value.future_times || []).map(Number); return [tf, { times, duration: value.duration == null ? null : Number(value.duration), durations: (value.durations || []).map(Number), futureTimes, futureDurations: (value.future_durations || []).map(Number), allTimes: [...times, ...futureTimes], }]; }), ); } updateTrendlineTime( tf, time, duration = null, firstTime = null, previousTime = null, futureTimes = [], futureDurations = [], ) { if (this.trendlineGeometryMode !== 'source_tf') return true; let source = this.trendlineSourceSeries.get(tf); if (!source) { source = { times: [], duration: duration == null ? null : Number(duration), durations: [], futureTimes: [], futureDurations: [], allTimes: [], }; this.trendlineSourceSeries.set(tf, source); } const t = Number(time); const lastBeforeUpdate = source.times[source.times.length - 1]; if (lastBeforeUpdate != null && t > lastBeforeUpdate && previousTime != null && lastBeforeUpdate !== Number(previousTime)) { return false; } if (lastBeforeUpdate == null && previousTime != null) return false; if (firstTime != null) { const first = Number(firstTime); const remove = source.times.findIndex(value => value >= first); const count = remove < 0 ? source.times.length : remove; if (count > 0) { source.times.splice(0, count); if (source.duration == null) source.durations.splice(0, count); } } const last = source.times.length - 1; if (last < 0 || t > source.times[last]) { source.times.push(t); if (source.duration == null) source.durations.push(Number(duration)); } else if (t === source.times[last] && source.duration == null && duration != null) { source.durations[last] = Number(duration); } source.futureTimes = futureTimes.map(Number); source.futureDurations = futureDurations.map(Number); source.allTimes = [...source.times, ...source.futureTimes]; return true; } /** * Empty slots past the last bar, so the axis can label the projection area. * * Times are extrapolated by repeating the most recent bar interval, which is * exactly what `timeAtIndex` does for the projections themselves. That is * approximate across a session break — futures do not trade through the * daily halt or the weekend, so a label far to the right drifts from the * real clock. Being consistent with the projection matters more than being * right in the abstract: an axis that disagreed with the line drawn above it * would be worse than one that is uniformly optimistic. Session-accurate * projection needs the server's session rules, which are not on the client. */ futureTimes(bars) { if (bars.length < 2) return []; const last = bars[bars.length - 1]; const step = ConfluenceChart.barInterval(bars); if (step <= 0) return []; const slots = []; for (let index = 1; index <= ConfluenceChart.FUTURE_SLOTS; index += 1) { slots.push({ time: last.t + index * step, value: 0 }); } return slots; } timeScaleSlots(bars) { const step = ConfluenceChart.barInterval(bars); if (!(step > 0)) return []; const slots = []; for (let index = 1; index < bars.length; index += 1) { const gap = bars[index].t - bars[index - 1].t; if (gap <= step || gap > ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) continue; for (let time = bars[index - 1].t + step; time < bars[index].t; time += step) { slots.push({ time, value: 0 }); } } return [...slots, ...this.futureTimes(bars)]; } 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; const advanced = !last || bar.t > last.t; this.keepLivePriceVisible(bar.c); this.candles.update(this.toCandle(bar)); this.updateCurrentPrice(bar.c); this.volume.update(ConfluenceChart.toVolume(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); if (advanced) this.extendManualLineSeries(last, bar); if (advanced) this.syncRthLines(); this.scheduleOverlays(true); } extendManualLineSeries(previous, bar) { const step = ConfluenceChart.barInterval(this.bars); if (!(step > 0)) return; // A session gap invalidates the old future horizon. Rebuild only the small // manual-line series; ordinary bars extend their already-owned future tail. if (previous && bar.t - previous.t > step) { this.futureSpace.setData(this.timeScaleSlots(this.bars)); for (const level of this.levels) { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue; this.levelSeries.get(level.id)?.series.setData(this.seriesLineData(level)); } return; } const futureTime = bar.t + ConfluenceChart.FUTURE_SLOTS * step; this.futureSpace.update({ time: futureTime, value: 0 }); for (const level of this.levels) { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level) || futureTime < level.anchor_t || futureTime > (level.cutoff_t ?? Infinity)) continue; const entry = this.levelSeries.get(level.id); const value = this.linePriceAtIndex(level, this.indexAt(futureTime)); if (entry && value != null) entry.series.update({ time: futureTime, value }); } } setFibs(fibs) { this.fibs = fibs || []; this.syncFibLines(); this.scheduleOverlays(true); } static fibPrices(fib) { const start = Number(fib.anchor_p); const end = start + Number(fib.slope || 0) * (Number(fib.last_t) - Number(fib.anchor_t)); return ConfluenceChart.FIB_RATIOS.map(item => ({ ...item, price: start + (end - start) * item.r, })); } syncFibLines() { if (this.fibPrimitive) this.fibPrimitive.setFibs(this.fibs.filter(fib => !fib.hidden)); } setComments(comments) { this.comments = comments; this.scheduleOverlays(true); } setCurrentPriceAnimation(enabled) { this.animateCurrentPrice = Boolean(enabled); if (!this.currentPricePulse) return; const active = this.animateCurrentPrice && this.lastCurrentPrice != null; this.currentPricePulse.hidden = !active; this.currentPricePulse.classList.toggle('active', active); } setAutoScrollLivePrice(enabled) { this.autoScrollLivePrice = Boolean(enabled); this.keepLivePriceVisible(this.lastCurrentPrice); } keepLivePriceVisible(price) { if (!this.autoScrollLivePrice || !this.manualPriceRange || price == null) return; const plot = this.plotCanvas(); const y = this.candles.priceToCoordinate(price); if (!plot || (y !== null && y >= 0 && y <= plot.clientHeight)) return; this.manualPriceRange = null; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); } updateCurrentPrice(price) { if (price == null) return; const options = { price, color: '#b7771d', lineWidth: 1, lineStyle: LightweightCharts.LineStyle.Dotted, axisLabelVisible: true, title: '', }; if (this.currentPriceLine) this.currentPriceLine.applyOptions(options); else this.currentPriceLine = this.candles.createPriceLine(options); this.lastCurrentPrice = price; if (!this.currentPricePulse) return; requestAnimationFrame(() => { const y = this.candles.priceToCoordinate(price); if (y == null) return; this.currentPricePulse.style.top = `${Math.round(y)}px`; this.currentPricePulse.hidden = !this.animateCurrentPrice; this.currentPricePulse.classList.toggle('active', this.animateCurrentPrice); }); } setCommentHandlers({ onToggle, onMove, onSelect }) { this.onCommentToggle = onToggle; this.onCommentMove = onMove; this.onCommentSelect = onSelect; } installPinchZoom() { if (!this.chartEl) return; const point = touch => ({ x: touch.clientX, y: touch.clientY }); const separation = touches => ({ x: Math.abs(touches[1].clientX - touches[0].clientX), y: Math.abs(touches[1].clientY - touches[0].clientY), }); const midpoint = touches => ({ x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2, }); const gap = touches => Math.hypot( touches[1].clientX - touches[0].clientX, touches[1].clientY - touches[0].clientY, ); this.touchStartListener = event => { if (event.touches.length === 1) { const touch = event.touches[0]; this.touchTap = { x: touch.clientX, y: touch.clientY, at: Date.now(), annotation: Boolean(event.target.closest?.('.chart-comment')), }; this.touchTwo = null; return; } this.touchTap = null; this.touchTwo = null; if (event.touches.length !== 2) return; const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) return; const touches = [...event.touches]; const insideChart = touches.every(touch => { const p = point(touch); return p.x >= plot.left && p.x <= this.chartEl.getBoundingClientRect().right && p.y >= plot.top && p.y <= plot.bottom; }); if (!insideChart) return; const distances = separation(touches); const mid = midpoint(touches); const centerX = mid.x - plot.left; const centerY = mid.y - plot.top; const margins = this.chart.priceScale('right').options().scaleMargins; const top = this.candles.coordinateToPrice(plot.height * margins.top); const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom)); const anchor = this.candles.coordinateToPrice(centerY); const timeRange = this.chart.timeScale().getVisibleLogicalRange(); if ([top, bottom, anchor].some(value => value == null) || !timeRange) return; this.touchTwo = { kind: null, mode: distances.x >= distances.y ? 'time' : 'price', distance: Math.max(distances[distances.x >= distances.y ? 'x' : 'y'], 1), startGap: Math.max(gap(touches), 1), startMid: mid, anchor, minValue: Math.min(top, bottom), maxValue: Math.max(top, bottom), timeRange, timeAnchor: timeRange.from + (timeRange.to - timeRange.from) * centerX / plot.width, plotWidth: plot.width, pricePerPx: (Math.max(top, bottom) - Math.min(top, bottom)) / Math.max(plot.height * (1 - margins.top - margins.bottom), 1), frame: null, latest: null, }; event.preventDefault(); }; this.touchMoveListener = event => { if (event.touches.length === 1 && this.touchTap) { const touch = event.touches[0]; if (Math.hypot(touch.clientX - this.touchTap.x, touch.clientY - this.touchTap.y) > 10) { this.touchTap = null; this.mousePan = null; } } if (!this.touchTwo || event.touches.length !== 2) return; event.preventDefault(); this.touchTwo.latest = [...event.touches]; if (this.touchTwo.frame) return; this.touchTwo.frame = requestAnimationFrame(() => this.applyTouchTwo()); }; this.applyTouchTwo = () => { const gesture = this.touchTwo; if (!gesture?.latest) return; gesture.frame = null; const touches = gesture.latest; const mid = midpoint(touches); if (!gesture.kind) { const midDelta = Math.hypot(mid.x - gesture.startMid.x, mid.y - gesture.startMid.y); const gapDelta = Math.abs(gap(touches) - gesture.startGap); if (midDelta < 10 && gapDelta < 10) return; gesture.kind = midDelta > gapDelta ? 'pan' : 'pinch'; } if (gesture.kind === 'pan') { const span = gesture.timeRange.to - gesture.timeRange.from; const shift = -(mid.x - gesture.startMid.x) / gesture.plotWidth * span; this.chart.timeScale().setVisibleLogicalRange({ from: gesture.timeRange.from + shift, to: gesture.timeRange.to + shift, }); const dPrice = (mid.y - gesture.startMid.y) * gesture.pricePerPx; this.manualPriceRange = { minValue: gesture.minValue + dPrice, maxValue: gesture.maxValue + dPrice, }; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); } else { const distances = separation(touches); const { mode, distance, anchor, minValue, maxValue, timeRange, timeAnchor } = gesture; const ratio = Math.max(0.1, distances[mode === 'time' ? 'x' : 'y'] / distance); if (mode === 'time') { this.manualPriceRange = { minValue, maxValue }; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); this.chart.timeScale().setVisibleLogicalRange({ from: timeAnchor + (timeRange.from - timeAnchor) / ratio, to: timeAnchor + (timeRange.to - timeAnchor) / ratio, }); } else { this.manualPriceRange = { minValue: anchor + (minValue - anchor) / ratio, maxValue: anchor + (maxValue - anchor) / ratio, }; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); } } this.scheduleOverlays(true); this.updateCurrentPrice(this.lastCurrentPrice); }; this.touchEndListener = event => { if (event.touches.length < 2 && this.touchTwo) { if (this.touchTwo.frame) cancelAnimationFrame(this.touchTwo.frame); this.touchTwo = null; } if (event.touches.length || !this.touchTap || this.armedTool) return; const tap = this.touchTap; this.touchTap = null; if (tap.annotation || Date.now() - tap.at > 500) return; const touch = event.changedTouches[0]; const chartPoint = touch && this.eventPoint({ clientX: touch.clientX, clientY: touch.clientY }); if (!chartPoint || !this.withinPlot(chartPoint) || chartPoint.t == null) return; this.suppressClickUntil = Date.now() + 500; const param = { point: chartPoint, time: chartPoint.t }; if (this.onChartClick) this.onChartClick(param); this.updateLineTooltip(param); }; this.chartEl.addEventListener('touchstart', this.touchStartListener, { passive: false, capture: true }); this.chartEl.addEventListener('touchmove', this.touchMoveListener, { passive: false, capture: true }); this.chartEl.addEventListener('touchend', this.touchEndListener, { passive: true, capture: true }); this.chartEl.addEventListener('touchcancel', this.touchEndListener, { passive: true, capture: true }); this.priceResetListener = event => { if (!this.manualPriceRange) return; const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot || event.clientX < plot.right) return; this.manualPriceRange = null; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); }; this.chartEl.addEventListener('dblclick', this.priceResetListener); } // Comments are DOM, not canvas: they hold arbitrary text, need to collapse, // and a floating one must ignore the time scale entirely. renderComments() { if (!this.commentLayer) return; const width = this.chartEl.clientWidth; const height = this.chartEl.clientHeight; const seen = new Set(); for (const comment of this.comments || []) { seen.add(comment.id); let node = this.commentNodes.get(comment.id); if (!node) { node = this.buildCommentNode(comment); this.commentNodes.set(comment.id, node); this.commentLayer.appendChild(node.root); } node.comment = comment; node.root.dataset.drawingId = comment.id; node.root.classList.toggle('collapsed', !!comment.collapsed); node.root.classList.toggle('floating', !comment.pinned); const iconClass = ConfluenceChart.symbolIcons[comment.icon]; node.root.classList.toggle('symbol', !!iconClass); node.collapse.hidden = !!iconClass; node.collapseIcon.className = comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'; node.grab.hidden = iconClass ? this.selectedDrawingId !== comment.id : comment.pinned; if (iconClass) { node.icon.className = `fa-solid ${iconClass}`; node.label.replaceChildren(node.icon); node.root.style.setProperty('--mark-scale', String(comment.scale || 1)); } else { node.label.textContent = comment.collapsed ? String(comment.number) : comment.note; node.root.style.removeProperty('--mark-scale'); } node.root.style.borderColor = comment.color; node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f'); node.root.title = comment.note; if (!comment.pinned) { // A fraction of the pane, so it holds its place through any zoom. node.root.style.left = `${Math.round(comment.x * width)}px`; node.root.style.top = `${Math.round(comment.y * height)}px`; node.root.classList.remove('off-left', 'off-right'); continue; } const placed = this.anchorCoordinate(comment.anchor_t); const x = placed.x; const y = this.candles.priceToCoordinate(comment.anchor_p); // Scrolled past: park it on the edge it went off, pointing that way, // rather than letting it vanish and be hard to find again. const offLeft = placed.before || (x !== null && x < 0); const offRight = placed.after || (x !== null && x > width); node.root.classList.toggle('off-left', offLeft && !offRight); node.root.classList.toggle('off-right', offRight); const boxW = node.root.offsetWidth; const boxH = node.root.offsetHeight; if (iconClass) { const rawX = x == null ? 4 : x - boxW / 2; const rawY = y == null ? 24 : y - boxH / 2; const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : rawX; node.root.style.left = `${Math.round(clampedX)}px`; node.root.style.top = `${Math.round(Math.min(Math.max(rawY, 4), height - boxH - 4))}px`; } else { const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : x; node.root.style.left = `${Math.round(clampedX)}px`; node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`; } } for (const [id, node] of this.commentNodes) { if (!seen.has(id)) { clearTimeout(node.focusTimer); node.root.remove(); this.commentNodes.delete(id); } } } /** * Where an anchor time sits on the current timeframe. * * timeToCoordinate answers only for times that are data points on the series, * so a comment placed on a 30m bar returned null the moment the chart * switched to 15m or 1h — and null was being read as "off the left edge", * which slid every such comment to the far left. The anchor is resolved to * the bar that *contains* it instead, which is timeframe-independent: a 09:30 * note lands on the 09:30 bar at 15m and on the 09:00 bar at 1h. */ anchorCoordinate(anchorT) { if (!this.bars.length) return { x: null, before: false, after: false }; const x = this.coordinateAtTime(anchorT); const width = this.overlayLayer?.clientWidth ?? this.chartEl?.clientWidth ?? 0; if (x == null) { return { x: null, before: anchorT < this.bars[0].t, after: anchorT > this.bars[this.bars.length - 1].t, }; } return { x, before: x < 0, after: x > width }; } buildCommentNode(comment) { const root = document.createElement('div'); root.className = 'chart-comment'; const focus = document.createElement('span'); focus.className = 'chart-comment-focus'; focus.setAttribute('aria-hidden', 'true'); const label = document.createElement('span'); label.className = 'chart-comment-text'; const icon = document.createElement('i'); const collapse = document.createElement('button'); collapse.type = 'button'; collapse.className = 'chart-comment-collapse'; collapse.title = 'Collapse or expand'; collapse.setAttribute('aria-label', 'Collapse or expand comment'); const collapseIcon = document.createElement('i'); collapse.appendChild(collapseIcon); const grab = document.createElement('button'); grab.type = 'button'; grab.className = 'chart-comment-grab'; grab.title = 'Drag'; grab.setAttribute('aria-label', 'Drag drawing'); const grabIcon = document.createElement('i'); grabIcon.className = 'fa-solid fa-grip-vertical'; grab.appendChild(grabIcon); root.append(focus, collapse, grab, label); const node = { root, focus, label, icon, collapse, collapseIcon, grab, comment }; root.addEventListener('click', event => { event.stopPropagation(); this.focusComment(node.comment.id); if (this.onCommentSelect) this.onCommentSelect(node.comment); }); collapse.addEventListener('click', event => { event.stopPropagation(); this.focusComment(node.comment.id); if (this.onCommentSelect) this.onCommentSelect(node.comment); if (this.onCommentToggle) this.onCommentToggle(node.comment); }); grab.addEventListener('click', event => event.stopPropagation()); // Floating comments move in screen space. Symbols can also be moved while // pinned, in which case their bar and tick-snapped price are updated. grab.addEventListener('pointerdown', event => { if (node.comment.pinned && !node.comment.icon) return; event.preventDefault(); event.stopPropagation(); this.focusComment(node.comment.id); if (this.onCommentSelect) this.onCommentSelect(node.comment); const bounds = this.plotCanvas().getBoundingClientRect(); const move = moveEvent => { root.style.left = `${Math.round(moveEvent.clientX - bounds.left - root.offsetWidth / 2)}px`; root.style.top = `${Math.round(moveEvent.clientY - bounds.top - root.offsetHeight / 2)}px`; }; const up = upEvent => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); if (node.comment.pinned && node.comment.icon) { const point = this.eventPoint(upEvent); if (point && this.withinPlot(point) && this.onCommentMove) { this.onCommentMove(node.comment, { anchor_t: Math.round(point.t), anchor_p: ConfluenceChart.snapToTick(point.p), }); } else { this.renderComments(); } return; } const x = (upEvent.clientX - bounds.left - 8) / bounds.width; const y = (upEvent.clientY - bounds.top - 8) / bounds.height; const clamp = value => Math.min(Math.max(value, 0), 1); if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) }); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }); return node; } // 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); } static isContextLevel(level) { return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal'; } seriesTitle(level) { if (ConfluenceChart.isContextLevel(level)) return ''; if (level.kind === 'manual') { return level.id === this.selectedLineId ? level.label : `#${level.number}`; } return level.label; } // 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: level.kind !== 'horizontal', title: this.seriesTitle(level), }; const existing = this.priceLines.get(level.id); if (existing) existing.line.applyOptions(options); else { const host = 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 dailyChart = this.bars[0]?.tf === '1d'; const options = { color: ConfluenceChart.levelColor(level), lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1), // A daily value is held constant while projected over intraday bars, // but on the daily chart each point is a new sample and should connect // normally rather than drawing a staircase. lineType: isMa && !dailyChart ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null, title: this.seriesTitle(level), // Overlays outside the visible price range must not flatten the candles. autoscaleInfoProvider: () => null, }; if (!entry) { entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, { ...options, priceScaleId: 'right' }), }; 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 = level.kind === 'manual' ? this.seriesLineData(level) : 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.scheduleOverlays(true); } renderContextLabels() { if (!this.contextLabelLayer || !this.candles) return; const height = this.plotCanvas()?.getBoundingClientRect().height || 0; const labels = this.levels .filter(level => !level.hidden && ConfluenceChart.isContextLevel(level)) .map(level => { const points = level.points || []; const value = level.current_p ?? points[points.length - 1]?.[1] ?? level.anchor_p; const y = value == null ? null : this.candles.priceToCoordinate(value); return { level, value, y }; }) .filter(item => item.y != null && item.y >= 0 && item.y <= height) .sort((a, b) => a.y - b.y); const gap = 18; const top = 10; const bottom = Math.max(top, height - 10); labels.forEach((item, index) => { item.placedY = index === 0 ? Math.max(top, item.y) : Math.max(item.y, labels[index - 1].placedY + gap); }); if (labels.length && labels[labels.length - 1].placedY > bottom) { const shift = labels[labels.length - 1].placedY - bottom; labels.forEach(item => { item.placedY -= shift; }); } this.contextLabelLayer.replaceChildren(...labels.map(({ level, value, y, placedY }) => { const node = document.createElement('div'); node.className = 'chart-context-label'; node.textContent = `${level.label} ${Number(value).toFixed(2)}`; node.style.top = `${Math.max(top, placedY)}px`; node.style.borderColor = ConfluenceChart.levelColor(level); return node; })); } overlayKey() { const range = this.chart?.timeScale().getVisibleLogicalRange(); if (!range) return ''; const price = this.manualPriceRange; return `${range.from.toFixed(4)}:${range.to.toFixed(4)}:${price?.minValue ?? ''}:${price?.maxValue ?? ''}`; } scheduleOverlays(force = false) { this.overlayForce = this.overlayForce || force; if (this.overlayFrame != null) return; this.overlayFrame = requestAnimationFrame(() => { this.overlayFrame = null; const forceDraw = this.overlayForce; this.overlayForce = false; const key = this.overlayKey(); if (!forceDraw && key && key === this.overlayRangeKey) return; this.overlayRangeKey = key; this.renderAnchorHandles(); this.renderLineBridges(); this.renderProjectionDiagnostic(); this.renderComments(); this.renderContextLabels(); }); } attachPointers(kind) { if (this.pointerAttached[kind]) return; this.pointerAttached[kind] = true; if (kind === 'tool') { window.addEventListener('pointermove', this.toolMoveListener); window.addEventListener('pointerup', this.toolUpListener); } else if (kind === 'anchor') { window.addEventListener('pointermove', this.anchorMoveListener); window.addEventListener('pointerup', this.anchorUpListener); } else if (kind === 'line') { window.addEventListener('pointermove', this.lineMoveListener); window.addEventListener('pointerup', this.lineUpListener); } } detachPointers(kind) { if (!this.pointerAttached[kind]) return; this.pointerAttached[kind] = false; if (kind === 'tool') { window.removeEventListener('pointermove', this.toolMoveListener); window.removeEventListener('pointerup', this.toolUpListener); } else if (kind === 'anchor') { window.removeEventListener('pointermove', this.anchorMoveListener); window.removeEventListener('pointerup', this.anchorUpListener); } else if (kind === 'line') { window.removeEventListener('pointermove', this.lineMoveListener); window.removeEventListener('pointerup', this.lineUpListener); } } // --- 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 ? false : { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: false, vertTouchDrag: false, }, handleScale: tool ? false : { mouseWheel: true, pinch: false, axisPressedMouseMove: { time: true, price: true }, axisDoubleClickReset: { time: true, price: true }, }, }); this.hideSnapDot(); this.chartEl.classList.toggle('armed', Boolean(tool)); this.pendingAnchor = null; this.clearGesture(); this.clearLinePreview(); if (tool) this.attachPointers('tool'); else this.detachPointers('tool'); } /** * Pixels between the element's left edge and the plot area. * * Lightweight Charts measures coordinates from the plot, not the element. * The helper remains the single source of truth if axes or panes change the * plot bounds again. */ /** Put the overlay container exactly over the plot area. */ syncOverlayLayer() { if (!this.overlayLayer || !this.chartEl) return; const canvas = this.plotCanvas(); if (!canvas) return; const chartRect = this.chartEl.getBoundingClientRect(); const plot = canvas.getBoundingClientRect(); this.overlayLayer.style.left = `${Math.round(plot.left - chartRect.left)}px`; this.overlayLayer.style.top = `${Math.round(plot.top - chartRect.top)}px`; this.overlayLayer.style.width = `${Math.round(plot.width)}px`; this.overlayLayer.style.height = `${Math.round(plot.height)}px`; } /** * Keep overlays aligned as Lightweight Charts sizes its internal price scales. * * The chart element itself does not resize when an axis label changes the * plot bounds; the plot canvas does. Observing the canvas catches both initial * settling and later digit-width changes. */ observePlotCanvas() { const canvas = this.plotCanvas(); if (!canvas) return; if (canvas !== this.observedPlotCanvas) { if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); this.observedPlotCanvas = canvas; this.plotResizeObserver = new ResizeObserver(() => { this.syncOverlayLayer(); this.scheduleOverlays(true); if (this.armedTool) this.renderGesture(); }); this.plotResizeObserver.observe(canvas); } this.syncOverlayLayer(); } plotCanvas() { if (!this.chartEl) return null; return [...this.chartEl.querySelectorAll('canvas')] .sort((a, b) => (b.width * b.height) - (a.width * a.height))[0] || null; } plotOffsetX() { if (!this.chartEl) return 0; const canvas = this.plotCanvas(); if (!canvas) return 0; return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left; } eventPoint(event) { const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) return null; const x = event.clientX - plot.left; const y = event.clientY - plot.top; const price = this.candles.coordinateToPrice(y); if (price == null) return null; const time = this.chart.timeScale().coordinateToTime(x); const index = time == null ? this.indexAtCoordinate(x) : this.indexAt(Number(time)); const slotted = index == null ? (time == null ? null : Number(time)) : this.timeAtIndex(Math.round(index)); return { x, y, p: price, t: slotted }; } placementAtClient(clientX, clientY) { const point = this.eventPoint({ clientX, clientY }); if (!point || !this.withinPlot(point) || point.t == null) return null; return { time: point.t, price: ConfluenceChart.snapToTick(point.p), x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1), y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1), }; } indexAtCoordinate(x) { if (!this.bars.length) return null; const scale = this.chart.timeScale(); const logical = scale.coordinateToLogical(x); const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t); const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX); if (logical == null || lastLogical == null) return null; return this.bars.length - 1 + Number(logical) - Number(lastLogical); } coordinateAtIndex(index) { if (!this.bars.length) return null; const scale = this.chart.timeScale(); if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t); const last = this.bars.length - 1; const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index); const upper = lower + 1; const x1 = scale.timeToCoordinate(this.bars[lower].t); const x2 = scale.timeToCoordinate(this.bars[upper].t); if (x1 == null || x2 == null) return null; return x1 + (x2 - x1) * (index - lower); } coordinateAtTime(time) { if (!this.bars.length) return null; // Interpolate between the displayed candles' own pixel coordinates. The // shared scale may contain foreign timestamps from other series, and its // logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h. return this.coordinateAtIndex(this.indexAt(time)); } startToolGesture(event) { if (event.button !== 0) return; const point = this.eventPoint(event); if (!point) return; if (!this.armedTool) { if (this.withinPlot(point)) { this.mousePan = { start: point }; this.attachPointers('tool'); } return; } event.preventDefault(); this.gesture = { start: point, end: point }; this.chartEl.setPointerCapture?.(event.pointerId); this.renderGesture(); } moveToolGesture(event) { if (!this.armedTool) { this.moveMousePan(event); return; } const point = this.eventPoint(event); if (!point) return; // The listener is on window, so it fires over the sidebar and the price // axis too. Reported from a real session: x=1409 on a 1280-wide chart, which // has no time under it — and the snap fell back to the newest bar, throwing // the dot to the right-hand edge. if (!this.withinPlot(point)) { this.hideSnapDot(); return; } if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point); 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)); } showSnapDot(snapped, point = {}) { if (snapped.t == null) return this.hideSnapDot(); const x = this.coordinateAtTime(snapped.t); const y = this.candles.priceToCoordinate(snapped.p); if (x == null || y == null) return this.hideSnapDot(); this.snapDot.hidden = false; this.snapDot.style.left = `${Math.round(x)}px`; this.snapDot.style.top = `${Math.round(y)}px`; // Says which it is before you commit, since the extreme decides the side. this.snapDot.dataset.side = snapped.snappedSide || ''; // Diagnostic mode: report what the client computed so it can be read on // the server. Off unless asked for, because the browser is usually on a // different machine and this is the only way to see its numbers. // turn on: ?diag=1 (remembered) turn off: ?diag=0 if (ConfluenceChart.diagnosticMode() && snapped.t != null && this.bars.length) { const step = this.bars.length > 1 ? this.bars[this.bars.length - 1].t - this.bars[this.bars.length - 2].t : 60; const drift = point.t == null ? null : Math.abs(snapped.t - point.t); { const now = Date.now(); if (!this._lastSnapReport || now - this._lastSnapReport > 900) { this._lastSnapReport = now; const body = { cursor_t: point.t, cursor_p: point.p, cursor_x: Math.round(point.x), snapped_t: snapped.t, snapped_p: snapped.p, bars_held: this.bars.length, first_bar_t: this.bars[0].t, last_bar_t: this.bars[this.bars.length - 1].t, chart_w: this.chartEl.clientWidth, chart_h: this.chartEl.clientHeight, cursor_y: Math.round(point.y), dot_y: this.snapDot && !this.snapDot.hidden ? Math.round(this.snapDot.getBoundingClientRect().top - this.chartEl.getBoundingClientRect().top) + 5 : null, expected_y: Math.round(y), bar_low_y: (() => { const bar = this.bars.find(candle => candle.t === snapped.t); const at = bar && this.candles.priceToCoordinate(bar.l); return at == null ? null : Math.round(at); })(), bar_high_y: (() => { const bar = this.bars.find(candle => candle.t === snapped.t); const at = bar && this.candles.priceToCoordinate(bar.h); return at == null ? null : Math.round(at); })(), note: point.t == null ? 'coordinateToTime returned null' : `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`, }; this.lastSnapDiagnostic = body; fetch('/api/debug/snap', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) }, body: JSON.stringify(body), }).catch(() => {}); } } } const side = snapped.snappedSide === 'resistance' ? 'H' : snapped.snappedSide === 'support' ? 'L' : ''; const clock = new Date(snapped.t * 1000) .toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' }); this.snapLabel.hidden = false; this.snapLabel.dataset.side = snapped.snappedSide || ''; this.snapLabel.textContent = `${snapped.p.toFixed(2)}${side ? ' ' + side : ''} · ${clock}`; // Flipped to the left near the right-hand edge so it never leaves the pane. const flip = x > this.chartEl.clientWidth - 120; this.snapLabel.style.left = `${Math.round(flip ? x - 12 : x + 12)}px`; this.snapLabel.style.top = `${Math.round(y)}px`; this.snapLabel.style.transform = flip ? 'translate(-100%, -50%)' : 'translate(0, -50%)'; if (point.x != null && point.y != null) { this.snapLeaderLine.setAttribute('x1', point.x); this.snapLeaderLine.setAttribute('y1', point.y); this.snapLeaderLine.setAttribute('x2', x); this.snapLeaderLine.setAttribute('y2', y); this.snapLeader.style.display = ''; } else { this.snapLeader.style.display = 'none'; } } hideSnapDot() { if (this.snapDot) this.snapDot.hidden = true; if (this.snapLabel) this.snapLabel.hidden = true; if (this.snapLeader) this.snapLeader.style.display = 'none'; } renderPending(a, b) { if (this.armedTool === 'fibonacci') { this.setFibPreview(a, b); return; } if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); } moveMousePan(event) { if (!this.mousePan) return; const point = this.eventPoint(event); if (!point) return; const gesture = this.mousePan; if (gesture.minValue == null) { const dx = point.x - gesture.start.x; const dy = point.y - gesture.start.y; if (Math.hypot(dx, dy) < 8) return; if (Math.abs(dy) <= Math.abs(dx)) { this.mousePan = null; return; } const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) { this.mousePan = null; return; } const margins = this.chart.priceScale('right').options().scaleMargins; const top = this.candles.coordinateToPrice(plot.height * margins.top); const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom)); if (top == null || bottom == null) { this.mousePan = null; return; } gesture.minValue = Math.min(top, bottom); gesture.maxValue = Math.max(top, bottom); gesture.pricePerPx = (gesture.maxValue - gesture.minValue) / Math.max(plot.height * (1 - margins.top - margins.bottom), 1); } const dPrice = (point.y - gesture.start.y) * gesture.pricePerPx; this.manualPriceRange = { minValue: gesture.minValue + dPrice, maxValue: gesture.maxValue + dPrice, }; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); this.updateCurrentPrice(this.lastCurrentPrice); } finishToolGesture(event) { this.mousePan = null; if (!this.armedTool) this.detachPointers('tool'); if (!this.armedTool || !this.gesture) return; const tool = this.armedTool; const { start, end } = this.gesture; this.clearGesture(); this.chartEl.releasePointerCapture?.(event.pointerId); if (tool === 'comment' || tool === 'symbol') { // Both placements come from one gesture: the time and price under the // cursor for a pinned comment, and the same spot as a fraction of the // pane for a floating one, so the tool decides which without a second // interaction. this.onToolComplete?.({ tool, time: end.t, price: ConfluenceChart.snapToTick(end.p), x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1), y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1), }); return; } 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. // Deliberate drag or click? At 5px a twitch while pressing the second click // counted as a drag, which threw away the anchor placed by the first click // and restarted the line under the cursor. Making a pending anchor always // win fixed that and broke the other direction: a real press-drag begun // after an abandoned click was hijacked by that stale anchor, starting the // line far from where the drag did. So the threshold decides, and it is // wide enough that only a deliberate drag clears the anchor. const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD; const b = this.snapPoint(end); if (!dragged && !this.pendingAnchor) { this.pendingAnchor = b; this.renderPending(b, b); return; } // A drag says everything it needs to on its own; anything half-placed // before it is abandoned rather than silently adopted. const a = dragged ? this.snapPoint(start) : this.pendingAnchor; this.pendingAnchor = null; this.clearLinePreview(); if (a.t == null || b.t == null) return; if (tool !== 'fibonacci' && a.t === b.t) return; if (tool === 'fibonacci' && a.p === b.p) return; this.onToolComplete?.({ tool, start: a, end: b }); } /** * Trendline anchors snap to bar extremes; levels snap to the tick grid. * * With snapping on, an anchor always lands on the nearer extreme of the * nearest bar — there is no proximity gate. An 8px one used to let the * cursor fall between the high and the low and snap to neither, so the * preview showed a raw mid-bar price and the side quietly fell back to the * dropdown. Which extreme you land on *is* the side: a high is resistance, a * low is support, so the choice is made by where you point rather than by a * control you have to remember to set. An already-snapped point carries no * cursor y, so it is returned untouched rather than re-snapped against NaN. */ /** Is this point over the plotted area, rather than an axis or the sidebar? */ withinPlot(point) { const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) return true; const width = plot.width; const height = plot.height; return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height; } /** * The bar nearest a pixel, when the time scale will not name one. * * coordinateToTime answers null over the right-hand whitespace and the price * axis. Treating that as "the newest bar" put the anchor at the live edge * however far away the cursor was. */ barNearestX(x) { const index = this.indexAtCoordinate(x); if (index != null) return this.timeAtIndex(Math.round(index)); let best = null; const timeScale = this.chart.timeScale(); for (const bar of this.bars) { const coordinate = timeScale.timeToCoordinate(bar.t); if (coordinate == null) continue; const distance = Math.abs(coordinate - x); if (!best || distance < best.distance) best = { distance, t: bar.t }; } return best ? best.t : null; } snapPoint(point) { const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x)); const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null }; if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; const last = this.bars[this.bars.length - 1]; if (fallbackT > last.t) { const index = Math.round(this.indexAt(fallbackT)); return { t: this.timeAtIndex(index), p: ConfluenceChart.snapToTick(point.p), snappedSide: null }; } // An already-snapped point carries no cursor position; return it untouched // rather than measuring against undefined. if (point.y == null) return base; // Horizontal position chooses the bar, vertical position chooses which of // its extremes. Nothing else — a nearest-in-2D search was tried and is // wrong: sweeping along the bottom of the chart, whichever nearby bar had // the lowest low won on total distance, so the dot skipped off the bar // under the cursor entirely instead of tracing each bar's low in turn. 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]; return { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; } 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 (this.armedTool === 'fibonacci') { this.setFibPreview(a, b); return; } 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; } setDrawingColorDescriptor(handler) { this.describeDrawingColor = handler; } setLineEndHandler(handler) { this.onLineEnd = handler; } setLineDuplicateHandler(handler) { this.onLineDuplicate = handler; } setLineDeleteHandler(handler) { this.onLineDelete = handler; } setSelectedLine(id) { this.selectedLineId = id; this.hideContextMenu(); for (const level of this.levels.filter(value => value.kind === 'manual')) { if (ConfluenceChart.isFlat(level)) { this.priceLines.get(level.id)?.line.applyOptions({ title: this.seriesTitle(level) }); } else { this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) }); } } this.renderAnchorHandles(); } setSelectedDrawing(id) { this.selectedDrawingId = id; this.renderComments(); } focusLine(id) { this.setSelectedLine(id); const level = this.levels.find(value => value.id === id && value.kind === 'manual'); if (!level) return; if (ConfluenceChart.isFlat(level)) { const y = this.candles.priceToCoordinate(level.anchor_p); if (y == null) return; this.lineFocus.setAttribute('x1', 0); this.lineFocus.setAttribute('y1', y); this.lineFocus.setAttribute('x2', this.overlayLayer.getBoundingClientRect().width); this.lineFocus.setAttribute('y2', y); } else { if (this.lineHitTarget.hasAttribute('hidden')) return; for (const attribute of ['x1', 'y1', 'x2', 'y2']) { this.lineFocus.setAttribute(attribute, this.lineHitTarget.getAttribute(attribute)); } } this.lineFocus.setAttribute('stroke', ConfluenceChart.levelColor(level)); const lineWidth = level.line_width || 2; this.lineFocus.style.setProperty('--line-focus-start-width', `${lineWidth + 10}px`); this.lineFocus.style.setProperty('--line-focus-width', `${lineWidth}px`); this.lineFocus.removeAttribute('hidden'); this.lineFocus.style.animation = 'none'; void this.lineFocus.getBoundingClientRect(); this.lineFocus.style.animation = ''; clearTimeout(this.lineFocusTimer); this.lineFocusTimer = setTimeout(() => this.lineFocus.setAttribute('hidden', ''), 800); } focusComment(id) { const node = this.commentNodes.get(id); if (!node) return; clearTimeout(node.focusTimer); node.root.classList.remove('focus'); void node.root.getBoundingClientRect(); node.root.classList.add('focus'); node.focusTimer = setTimeout(() => node.root.classList.remove('focus'), 800); } setFibPreview(start, end) { if (!this.fibPreview || start?.p == null || end?.p == null || start.p === end.p) return; if (this.previewLine) this.previewLine.setAttribute('hidden', ''); this.fibPreview.removeAttribute('hidden'); this.fibPreview.replaceChildren(); const width = this.overlayLayer?.clientWidth || this.chartEl.clientWidth; const originX = start.t == null ? 0 : (this.coordinateAtTime(start.t) ?? 0); const yStart = this.candles.priceToCoordinate(start.p); const yEnd = this.candles.priceToCoordinate(end.p); if (yStart != null && yEnd != null) { const rail = document.createElementNS('http://www.w3.org/2000/svg', 'line'); rail.setAttribute('x1', originX); rail.setAttribute('x2', originX); rail.setAttribute('y1', yStart); rail.setAttribute('y2', yEnd); rail.setAttribute('stroke', '#c8992f'); rail.setAttribute('stroke-width', 2); this.fibPreview.appendChild(rail); } for (const item of ConfluenceChart.FIB_RATIOS) { const y = this.candles.priceToCoordinate(start.p + (end.p - start.p) * item.r); if (y == null) continue; const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', originX); line.setAttribute('x2', width); line.setAttribute('y1', y); line.setAttribute('y2', y); const major = item.r === 0 || item.r === 0.5 || item.r === 0.618 || item.r === 1; line.setAttribute('stroke', '#c8992f'); line.setAttribute('stroke-width', 2); if (!major) line.setAttribute('stroke-dasharray', '4 4'); line.setAttribute('opacity', '0.9'); this.fibPreview.appendChild(line); } } 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', ''); if (this.fibPreview) { this.fibPreview.setAttribute('hidden', ''); this.fibPreview.replaceChildren(); } } barAtTime(time) { if (time == null || !this.bars.length) return null; let lo = 0; let hi = this.bars.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; const t = this.bars[mid].t; if (t === time) return this.bars[mid]; if (t < time) lo = mid + 1; else hi = mid - 1; } return null; } hideOhlc() { if (this.ohlcReadout) this.ohlcReadout.hidden = true; } updateOhlc(time) { const bar = this.barAtTime(time); if (!bar || !this.ohlcReadout) { this.hideOhlc(); return; } const up = bar.c >= bar.o; this.ohlcReadout.hidden = false; this.ohlcReadout.classList.toggle('up', up); this.ohlcReadout.classList.toggle('down', !up); this.ohlcReadout.textContent = `O ${bar.o.toFixed(2)} H ${bar.h.toFixed(2)} L ${bar.l.toFixed(2)} C ${bar.c.toFixed(2)}`; } updateLineTooltip(param) { const id = this.hitTest(param); const line = this.levels.find(level => level.id === id); this.tooltip.hidden = !line; if (!line) return; const color = this.describeDrawingColor?.(line.color); this.tooltip.textContent = [ `#${line.number} ${line.label}`, ConfluenceChart.slopeLabel(line), color?.name, color?.annotation, ].filter(Boolean).join('\n'); 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; const visible = this.visibleBarIndexes(); if (!visible) return null; for (const level of this.levels.filter(value => value.kind === 'manual' && ConfluenceChart.isFlat(value) && !value.hidden)) { const y = this.candles.priceToCoordinate(level.anchor_p); const distance = y == null ? Infinity : Math.abs(y - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const endIndex = Math.min(naturalEnd, visible.to); const pointIndex = this.indexAtCoordinate(param.point.x); if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue; const linePrice = this.linePriceAtIndex(level, pointIndex); const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice); if (lineY == null) continue; const distance = Math.abs(lineY - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } for (const fib of this.fibs.filter(item => !item.hidden)) { for (const level of ConfluenceChart.fibPrices(fib)) { const y = this.candles.priceToCoordinate(level.price); const distance = y == null ? Infinity : Math.abs(y - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: fib.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) { // Future slots use the median interval, not the tail gap. After the CME // settlement break the final gap is 65 minutes, and using it here makes a // 5m future projection advance thirteen bars per visible slot. const step = ConfluenceChart.barInterval(bars); 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); } sourceSeries(level) { if (this.trendlineGeometryMode !== 'source_tf') return null; const source = this.trendlineSourceSeries.get(level.tf); if (source) return source; if (this.bars[0]?.tf !== level.tf) return null; const duration = ConfluenceChart.tfSeconds[level.tf] || null; return { times: this.bars.map(bar => bar.t), duration, durations: duration == null ? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400) : [], futureTimes: [], futureDurations: [], allTimes: this.bars.map(bar => bar.t), }; } static sourceDuration(source, index) { if (source.duration != null) return source.duration; if (index < source.times.length) { return source.durations[index] ?? source.durations[source.durations.length - 1] ?? null; } const futureIndex = index - source.times.length; return source.futureDurations[futureIndex] ?? source.futureDurations[source.futureDurations.length - 1] ?? null; } static sourceTimes(source) { return source?.allTimes || source?.times || []; } static timeframeIndexAt(source, time, allowFuture = true) { const times = ConfluenceChart.sourceTimes(source); if (!times.length) return null; let lo = 0; let hi = times.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (times[mid] <= time) lo = mid + 1; else hi = mid; } const lower = lo - 1; if (lower >= 0 && times[lower] === time) return lower; if (lower < 0) return null; const duration = ConfluenceChart.sourceDuration(source, lower); const elapsed = time - times[lower]; if (!(duration > 0) || elapsed < 0) return null; if (elapsed > duration && !(allowFuture && lower === times.length - 1)) return null; return lower + elapsed / duration; } static timeframeTimeAt(source, index) { const times = ConfluenceChart.sourceTimes(source); if (!times.length || index < 0) return null; const lower = Math.min(Math.floor(index), times.length - 1); const duration = ConfluenceChart.sourceDuration(source, lower); if (!(duration > 0)) return null; return Math.round(times[lower] + (index - lower) * duration); } sourceLinePriceAt(level, time) { const source = this.sourceSeries(level); if (!source) return null; const start = ConfluenceChart.timeframeIndexAt(source, level.anchor_t); const end = ConfluenceChart.timeframeIndexAt(source, level.last_t); const target = ConfluenceChart.timeframeIndexAt(source, time); if (start == null || end == null || target == null) return null; if (start === end) return level.anchor_p; const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start); } shiftLineTime(level, time, bars) { const source = this.sourceSeries(level); if (!source) return this.timeAtIndex(this.indexAt(time) + bars); const index = ConfluenceChart.timeframeIndexAt(source, time); return index == null ? time : ConfluenceChart.timeframeTimeAt(source, index + bars) ?? time; } timeAtIndex(index) { const bars = this.bars; if (bars.length < 2) return bars[0]?.t ?? 0; if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t)); const last = bars.length - 1; if (index >= last) { const step = ConfluenceChart.barInterval(bars); return Math.round(bars[last].t + (index - last) * step); } const lower = Math.floor(index); const fraction = index - lower; return Math.round(bars[lower].t + fraction * (bars[lower + 1].t - bars[lower].t)); } visibleBarIndexes() { const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) return null; const from = this.indexAtCoordinate(0); const to = this.indexAtCoordinate(plot.width); return from == null || to == null ? null : { from, to }; } linePriceAtIndex(level, index) { if (this.trendlineGeometryMode === 'source_tf') { return this.sourceLinePriceAt(level, this.timeAtIndex(index)); } const start = this.indexAt(level.anchor_t); const end = this.indexAt(level.last_t); const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); return end === start ? level.anchor_p : level.anchor_p + (endPrice - level.anchor_p) * (index - start) / (end - start); } seriesLineData(level) { if (!this.bars.length) return []; let points; if (this.trendlineGeometryMode === 'source_tf') { const cutoff = level.cutoff_t ?? Infinity; points = this.bars .filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff) .map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) })) .filter(point => point.value != null); } else { const start = this.indexAt(level.anchor_t); const lastBar = this.bars.length - 1; const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t); const end = Math.min(lastBar, cutoff); if (start > end) return []; const anchorEnd = this.indexAt(level.last_t); const indexes = [start]; if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd); if (end > indexes[indexes.length - 1]) indexes.push(end); points = indexes.map(index => ({ time: this.timeAtIndex(index), value: this.linePriceAtIndex(level, index), })); } const cutoff = level.cutoff_t ?? Infinity; // futureSpace already owns these timestamps. Giving values to this same // LineSeries keeps the historical line and extension in one renderer. for (const slot of this.futureTimes(this.bars)) { if (slot.time < level.anchor_t || slot.time > cutoff) continue; const value = this.linePriceAtIndex(level, this.indexAt(slot.time)); if (value != null) points.push({ time: slot.time, value }); } return points; } renderLineBridges() { if (!this.lineBridgeLayer || !this.bars.length) return; const firstDisplayed = this.bars[0].t; const segment = (level, t1, p1, t2, p2) => { const x1 = this.coordinateAtTime(t1); const x2 = this.coordinateAtTime(t2); const y1 = p1 == null ? null : this.candles.priceToCoordinate(p1); const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2); if ([x1, x2, y1, y2].some(value => value == null)) return null; const node = document.createElementNS('http://www.w3.org/2000/svg', 'line'); node.setAttribute('x1', x1); node.setAttribute('y1', y1); node.setAttribute('x2', x2); node.setAttribute('y2', y2); node.setAttribute('stroke', ConfluenceChart.levelColor(level)); node.setAttribute('stroke-width', level.line_width || 2); return node; }; const bridges = []; for (const level of this.levels) { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue; const points = this.seriesLineData(level); if (!points.length) continue; const priceAt = time => this.trendlineGeometryMode === 'source_tf' ? this.sourceLinePriceAt(level, time) : this.linePriceAtIndex(level, this.indexAt(time)); const first = points[0]; if (level.anchor_t >= firstDisplayed && first.time > level.anchor_t) { const node = segment(level, level.anchor_t, priceAt(level.anchor_t), first.time, first.value); if (node) bridges.push(node); } const cutoff = level.cutoff_t; const last = points[points.length - 1]; if (cutoff != null && last.time < cutoff) { const node = segment(level, last.time, last.value, cutoff, priceAt(cutoff)); if (node) bridges.push(node); } } this.lineBridgeLayer.replaceChildren(...bridges); } renderProjectionDiagnostic() { if (!this.projectionDiagnostic) return; if (!ConfluenceChart.diagnosticMode() || this.bars.length < 2) { this.projectionDiagnostic.hidden = true; return; } const last = this.bars.length - 1; const future = this.futureTimes(this.bars)[0]; const plotHeight = this.plotCanvas()?.clientHeight || 0; if (!future || !plotHeight) { this.projectionDiagnostic.hidden = true; return; } const slope = (first, second) => { if (first.x == null || second.x == null || first.y == null || second.y == null) return null; return (second.y - first.y) / (second.x - first.x); }; const lines = this.levels.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)).map(level => { const previousTime = this.bars[last - 1].t; const currentTime = this.bars[last].t; const priceAt = time => this.linePriceAtIndex(level, this.indexAt(time)); const previous = { x: this.chart.timeScale().timeToCoordinate(previousTime), y: this.candles.priceToCoordinate(priceAt(previousTime)), p: priceAt(previousTime) }; const current = { x: this.chart.timeScale().timeToCoordinate(currentTime), y: this.candles.priceToCoordinate(priceAt(currentTime)), p: priceAt(currentTime) }; const next = { x: this.chart.timeScale().timeToCoordinate(future.time), y: this.candles.priceToCoordinate(priceAt(future.time)), p: priceAt(future.time) }; const series = this.levelSeries.get(level.id)?.series.data() .find(point => Number(point.time) === future.time)?.value; return { level, previous, current, next, series, historical: slope(previous, current), future: slope(current, next) }; }).filter(row => row.current.y != null && row.current.y >= 0 && row.current.y <= plotHeight); const text = (row, painted = null) => `#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} paint ${painted ? `${painted.historical?.toFixed(3) ?? '?'}/${painted.future?.toFixed(3) ?? '?'}` : '?/?'} ${row.series == null ? 'MISSING' : 'OK'}`; this.projectionDiagnostic.textContent = lines.map(row => text(row)).join('\n'); this.projectionDiagnostic.hidden = !lines.length; if (this.projectionDiagnosticFrame != null) cancelAnimationFrame(this.projectionDiagnosticFrame); this.projectionDiagnosticFrame = requestAnimationFrame(() => { this.projectionDiagnosticFrame = null; this.projectionDiagnostic.textContent = lines.map(row => text(row, { historical: this.paintedSlopeBetween(row.previous, row.current, ConfluenceChart.levelColor(row.level)), future: this.paintedSlopeBetween(row.current, row.next, ConfluenceChart.levelColor(row.level)), })).join('\n'); }); } paintedSlopeBetween(first, second, color) { if ([first.x, first.y, second.x, second.y].some(value => value == null)) return null; const at = fraction => ({ x: first.x + (second.x - first.x) * fraction, y: first.y + (second.y - first.y) * fraction, }); const a = at(0.25); const b = at(0.75); const y1 = this.paintedYAt(a.x, a.y, color); const y2 = this.paintedYAt(b.x, b.y, color); return y1 == null || y2 == null ? null : (y2 - y1) / (b.x - a.x); } paintedYAt(x, expectedY, color) { const match = /^#([0-9a-f]{6})$/i.exec(color || ''); if (!match) return null; const value = Number.parseInt(match[1], 16); const target = [(value >> 16) & 255, (value >> 8) & 255, value & 255]; const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) return null; const pageX = plot.left + x; const candidates = []; for (const canvas of this.chartEl.querySelectorAll('canvas')) { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height || pageX < rect.left || pageX > rect.right) continue; const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const pixelX = Math.round((pageX - rect.left) * scaleX); const top = Math.max(0, Math.floor((plot.top + expectedY - 8 - rect.top) * scaleY)); const bottom = Math.min(canvas.height - 1, Math.ceil((plot.top + expectedY + 8 - rect.top) * scaleY)); if (bottom < top) continue; try { const image = canvas.getContext('2d').getImageData( Math.max(0, pixelX - 1), top, Math.min(3, canvas.width - Math.max(0, pixelX - 1)), bottom - top + 1, ); for (let offset = 0; offset < image.data.length; offset += 4) { if (image.data[offset + 3] < 80) continue; const distance = Math.hypot( image.data[offset] - target[0], image.data[offset + 1] - target[1], image.data[offset + 2] - target[2], ); if (distance > 55) continue; const row = Math.floor((offset / 4) / image.width); candidates.push(rect.top - plot.top + (top + row) / scaleY); } } catch { /* An unreadable internal canvas contributes no measurement. */ } } if (!candidates.length) return null; candidates.sort((a, b) => Math.abs(a - expectedY) - Math.abs(b - expectedY)); return candidates[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, })); } fibAsHandleLevel() { const fib = this.fibs.find(item => item.id === this.selectedLineId && !item.hidden); if (!fib) return null; return { ...fib, kind: 'manual', slope: Number(fib.slope) || 0 }; } renderAnchorHandles() { const level = this.levels.find( value => value.id === this.selectedLineId && value.kind === 'manual' && !value.hidden, ) || this.fibAsHandleLevel(); if (!level || !this.anchorHandles.length) { this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); this.lineHitTarget?.setAttribute('hidden', ''); return; } if (ConfluenceChart.isFlat(level)) { const plot = this.plotCanvas()?.getBoundingClientRect(); const y = this.candles.priceToCoordinate(level.anchor_p); const handle = this.anchorHandles[0]; this.lineHitTarget.setAttribute('hidden', ''); this.anchorHandles[1].setAttribute('hidden', ''); if (!plot || y == null) { handle.setAttribute('hidden', ''); return; } handle.removeAttribute('hidden'); handle.setAttribute('cx', Math.max(20, plot.width - 24)); handle.setAttribute('cy', y); handle.setAttribute('fill', ConfluenceChart.levelColor(level)); return; } const points = [ { time: level.anchor_t, value: level.anchor_p }, { time: level.last_t, value: level.anchor_p + level.slope * (level.last_t - level.anchor_t), }, ]; const visible = this.visibleBarIndexes(); const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t); const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd); const hitStart = this.indexAt(level.anchor_t); const hitIndexes = []; if (hitEnd != null) { for (let index = Math.max(hitStart, visible?.from ?? hitStart); index <= hitEnd; index += 1) { hitIndexes.push(index); } if (!hitIndexes.length || hitIndexes[hitIndexes.length - 1] < hitEnd) hitIndexes.push(hitEnd); } const hitPoints = hitIndexes.map(index => ({ x: index == null ? null : this.coordinateAtIndex(index), y: index == null ? null : this.linePriceAtIndex(level, index), })).map(point => ({ x: point.x, y: point.y == null ? null : this.candles.priceToCoordinate(point.y), })); if (hitPoints.length < 2 || hitPoints.some(point => point.x == null || point.y == null)) { this.lineHitTarget.setAttribute('hidden', ''); } else { this.lineHitTarget.removeAttribute('hidden'); const first = hitPoints[0]; const last = hitPoints[hitPoints.length - 1]; this.lineHitTarget.setAttribute('points', hitPoints.map(point => `${point.x},${point.y}`).join(' ')); // Kept for diagnostics and the existing drag test, though the polyline's // actual hit region follows every sampled point. this.lineHitTarget.setAttribute('x1', first.x); this.lineHitTarget.setAttribute('y1', first.y); this.lineHitTarget.setAttribute('x2', last.x); this.lineHitTarget.setAttribute('y2', last.y); } points.forEach((point, index) => { const x = this.coordinateAtTime(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)); }); } startLineDrag(event) { if (event.button !== 0) return; const level = this.levels.find(value => value.id === this.selectedLineId); const point = this.eventPoint(event); if (!level || level.geometry_resolved === false || !point || point.t == null) return; event.preventDefault(); event.stopPropagation(); this.draggingLine = { id: level.id, start: point, original: { ...level }, moved: false, }; this.attachPointers('line'); event.currentTarget.setPointerCapture?.(event.pointerId); } moveLine(event) { if (!this.draggingLine) return; const point = this.eventPoint(event); const level = this.levels.find(value => value.id === this.draggingLine.id); if (!point || point.t == null || !this.withinPlot(point) || !level) return; event.preventDefault(); const { original, start } = this.draggingLine; const source = this.sourceSeries(original); const sourcePoint = source && ConfluenceChart.timeframeIndexAt(source, point.t); const sourceStart = source && ConfluenceChart.timeframeIndexAt(source, start.t); const indexShift = Math.round( sourcePoint != null && sourceStart != null ? sourcePoint - sourceStart : this.indexAt(point.t) - this.indexAt(start.t), ); const priceShift = ConfluenceChart.snapToTick(point.p - start.p); const anchorT = source ? this.shiftLineTime(original, original.anchor_t, indexShift) : this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); const lastT = source ? this.shiftLineTime(original, original.last_t, indexShift) : this.timeAtIndex(this.indexAt(original.last_t) + indexShift); const endPrice = original.anchor_p + original.slope * (original.last_t - original.anchor_t) + priceShift; level.anchor_t = anchorT; level.last_t = lastT; level.anchor_p = original.anchor_p + priceShift; level.slope = (endPrice - level.anchor_p) / (lastT - anchorT); if (original.cutoff_t != null) { level.cutoff_t = source ? this.shiftLineTime(original, original.cutoff_t, indexShift) : this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift); } this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); this.renderAnchorHandles(); this.renderLineBridges(); } finishLineDrag(event) { if (!this.draggingLine) return; event.preventDefault(); const level = this.levels.find(value => value.id === this.draggingLine.id); const moved = this.draggingLine.moved; this.draggingLine = null; this.detachPointers('line'); if (moved && level && this.onLineChange) this.onLineChange({ ...level }); } startAnchorDrag(event, anchor) { event.preventDefault(); event.stopPropagation(); const level = this.levels.find(value => value.id === this.selectedLineId); this.draggingAnchor = { id: this.selectedLineId, anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor, }; event.currentTarget.setPointerCapture?.(event.pointerId); this.attachPointers('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 point = this.eventPoint(event); if (!point || !this.withinPlot(point)) { this.hideSnapDot(); return; } const snapped = this.snapToBars ? this.snapPoint(point) : { ...point, p: ConfluenceChart.snapToTick(point.p) }; if (snapped.t == null || snapped.p == null) return; const time = snapped.t; const price = snapped.p; if (this.snapToBars) this.showSnapDot(snapped, point); if (this.draggingAnchor.anchor === 'price') { level.anchor_p = price; this.priceLines.get(level.id)?.line.applyOptions({ price }); this.renderAnchorHandles(); return; } 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.seriesLineData(level)); this.renderAnchorHandles(); this.renderLineBridges(); } finishAnchorDrag(event) { if (!this.draggingAnchor) return; event.preventDefault(); const level = this.levels.find(value => value.id === this.draggingAnchor.id); this.draggingAnchor = null; this.detachPointers('anchor'); this.hideSnapDot(); 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 point = this.eventPoint(event); if (!point || !this.withinPlot(point) || point.t == null) return; const cutoff = this.bars.reduce((nearest, bar) => Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest ).t; this.contextLineId = level.id; this.contextCutoff = cutoff > level.last_t ? cutoff : null; this.contextEndButton.hidden = this.contextCutoff == null; this.contextExtendButton.hidden = level.cutoff_t == null; this.contextMenu.hidden = false; const plot = this.plotCanvas().getBoundingClientRect(); this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`; this.contextMenu.style.top = `${Math.max(4, Math.min(point.y, plot.height - this.contextMenu.offsetHeight - 4))}px`; } hideContextMenu() { if (this.contextMenu) this.contextMenu.hidden = true; this.contextCutoff = null; this.contextLineId = null; } dismissContextMenu() { if (!this.contextMenu || this.contextMenu.hidden) return false; this.hideContextMenu(); return true; } 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.seriesLineData(level)); this.renderLineBridges(); this.hideContextMenu(); if (this.onLineEnd) this.onLineEnd({ ...level }); } extendSelectedLine() { const level = this.levels.find(value => value.id === this.selectedLineId); if (!level || level.cutoff_t == null) return; level.cutoff_t = null; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); this.renderLineBridges(); this.hideContextMenu(); if (this.onLineEnd) this.onLineEnd({ ...level }); } // Tinted by the bar's own direction, muted so the candles stay the subject. static toVolume(bar) { return { time: bar.t, value: bar.v, color: bar.c >= bar.o ? 'rgba(32,184,166,.38)' : 'rgba(239,91,63,.38)', }; } setRthEnabled(enabled) { this.rthEnabled = enabled; this.syncRthLines(); } syncRthLines() { if (!this.rthPrimitive) return; if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') { this.rthPrimitive.setMarks([]); return; } const first = this.bars[0].t; const last = this.bars[this.bars.length - 1].t; const marks = []; const seen = new Set(); for (const mark of ConfluenceChart.rthInstants(first, last)) { const time = this.barContaining(mark.t); if (time == null || seen.has(time)) continue; seen.add(time); marks.push({ t: time, kind: mark.kind }); } this.rthPrimitive.setMarks(marks); } barContaining(time) { const bars = this.bars; if (!bars.length || time < bars[0].t) return null; let lo = 0; let hi = bars.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (bars[mid].t <= time) lo = mid; else hi = mid - 1; } return bars[lo].t; } static easternParts(epochSec) { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).formatToParts(new Date(epochSec * 1000)) .filter(part => part.type !== 'literal') .map(part => [part.type, part.value]), ); return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour) % 24, minute: Number(parts.minute), }; } static epochAtEastern(year, month, day, hour, minute) { let guess = Date.UTC(year, month - 1, day, hour + 4, minute); for (let i = 0; i < 4; i += 1) { const got = ConfluenceChart.easternParts(guess / 1000); const delta = Date.UTC(year, month - 1, day, hour, minute) - Date.UTC(got.year, got.month - 1, got.day, got.hour, got.minute); if (delta === 0) break; guess += delta; } return Math.floor(guess / 1000); } static rthInstants(fromT, toT) { const marks = []; const seen = new Set(); let cursor = fromT - 12 * 3600; const stop = toT + 12 * 3600; while (cursor <= stop) { const parts = ConfluenceChart.easternParts(cursor); const key = `${parts.year}-${parts.month}-${parts.day}`; const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay(); if (!seen.has(key) && weekday !== 0 && weekday !== 6) { seen.add(key); marks.push({ t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 9, 30), kind: 'open', }); marks.push({ t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 16, 0), kind: 'close', }); } cursor += 12 * 3600; } return marks.filter(mark => mark.t >= fromT - 3600 && mark.t <= toT + 3600); } static slopeLabel(line) { if (ConfluenceChart.isFlat(line) || !line.slope) return ''; const perHour = line.slope * 3600; const decimals = Math.abs(perHour) >= 10 ? 1 : 2; return `${perHour > 0 ? '+' : ''}${perHour.toFixed(decimals)} /h`; } 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.plotResizeObserver) this.plotResizeObserver.disconnect(); if (this.overlayFrame != null) cancelAnimationFrame(this.overlayFrame); this.detachPointers('tool'); this.detachPointers('anchor'); this.detachPointers('line'); clearTimeout(this.lineFocusTimer); for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer); 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.touchStartListener) { this.chartEl.removeEventListener('touchstart', this.touchStartListener, true); this.chartEl.removeEventListener('touchmove', this.touchMoveListener, true); this.chartEl.removeEventListener('touchend', this.touchEndListener, true); this.chartEl.removeEventListener('touchcancel', this.touchEndListener, true); this.chartEl.removeEventListener('dblclick', this.priceResetListener); } if (this.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener); if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener); 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.FIB_RATIOS = [ { r: 0, label: '0' }, { r: 0.236, label: '23.6' }, { r: 0.382, label: '38.2' }, { r: 0.5, label: '50' }, { r: 0.618, label: '61.8' }, { r: 0.786, label: '78.6' }, { r: 1, label: '100' }, ]; ConfluenceChart.tfColors = { '1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b', '30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073', }; ConfluenceChart.tfSeconds = { '1m': 60, '2m': 120, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600, }; // 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.symbolIcons = { 'arrow-up': 'fa-arrow-up', 'arrow-down': 'fa-arrow-down', 'face-smile': 'fa-face-smile', 'hand-point-right': 'fa-hand-point-right', skull: 'fa-skull', 'face-laugh-squint': 'fa-face-laugh-squint', 'champagne-glasses': 'fa-champagne-glasses', 'arrow-left': 'fa-arrow-left', 'arrow-right': 'fa-arrow-right', hand: 'fa-hand', 'right-to-bracket': 'fa-right-to-bracket', play: 'fa-play', }; ConfluenceChart.levelColor = level => level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf]; window.ConfluenceChart = ConfluenceChart;