chart/static/chart.js
Chris Amow 22a638cf73 Label the axis past the last bar, and fix the Yahoo bars that exposed
Trendlines project into the whitespace beyond the newest candle, but the time
axis stopped there, so a converging pair could be seen without knowing when it
converges. Lightweight Charts only labels times present on its scale, so the
chart now carries whitespace points past the last bar: no value, nothing drawn,
but the axis has something to label and timeToCoordinate answers out there.

Future times repeat the most recent bar interval, which is what timeAtIndex
already does for the projections themselves. That drifts across the daily halt
and the weekend; agreeing with the projected line matters more than abstract
accuracy, and session-accurate projection needs server-side session rules the
client does not have.

Two bugs surfaced while measuring it. Padding meant to be five bars measured as
sixty-seven, because the interval came from the gap between the final two bars;
barInterval now takes a median over recent bars and ignores a ragged tail.

And that gap was two seconds on a one-minute chart because Yahoo stamps its
in-progress candle with the time of the request, while the poller emitted
anything newer than the last thing it sent. Every poll therefore appended a new
"1m" bar seconds after the previous one, interleaved with the real ones — live
in production, which is still on Yahoo. Timestamps are bucketed on parse, and
the final candle is emitted unclosed so it revises the current minute rather
than entering the aggregator and adding its volume to every higher timeframe
again on each poll. Verified live: eight consecutive bars, all aligned, all
sixty seconds apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:55:27 -05:00

1757 lines
74 KiB
JavaScript

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.previewLine = null;
this.projectionLayer = null;
this.bars = [];
this.levels = [];
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.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.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.contextLabelFrame = null;
this.onCommentToggle = null;
this.onCommentMove = null;
this.onCommentSelect = null;
this.lastSnapDiagnostic = null;
}
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;
/**
* 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', year: 'numeric', month: 'short', day: 'numeric' }
: { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
},
},
rightPriceScale: { borderVisible: 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: '#27825c', downColor: '#bd4545', borderVisible: true,
borderUpColor: '#1d6849', borderDownColor: '#963737',
wickUpColor: '#1d6849', wickDownColor: '#963737',
});
// 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.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
priceScaleId: 'right',
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.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments();
this.renderContextLabels();
});
});
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);
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.overlayLayer.appendChild(preview);
this.projectionLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.projectionLayer.classList.add('chart-projections');
this.projectionLayer.setAttribute('aria-hidden', 'true');
this.overlayLayer.appendChild(this.projectionLayer);
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', 'line');
this.lineHitTarget.classList.add('chart-line-hit');
this.lineHitTarget.setAttribute('hidden', '');
this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event));
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 duplicate = document.createElement('button');
duplicate.type = 'button';
duplicate.dataset.action = 'duplicate';
duplicate.textContent = 'Duplicate 10 bars right';
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, 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);
window.addEventListener('pointermove', this.toolMoveListener);
window.addEventListener('pointerup', this.toolUpListener);
this.anchorMoveListener = event => this.moveAnchor(event);
this.anchorUpListener = event => this.finishAnchorDrag(event);
window.addEventListener('pointermove', this.anchorMoveListener);
window.addEventListener('pointerup', this.anchorUpListener);
this.lineMoveListener = event => this.moveLine(event);
this.lineUpListener = event => this.finishLineDrag(event);
window.addEventListener('pointermove', this.lineMoveListener);
window.addEventListener('pointerup', this.lineUpListener);
this.contextListener = event => this.showContextMenu(event);
el.addEventListener('contextmenu', this.contextListener);
this.clickListener = event => {
this.hideContextMenu();
// A placement gesture ends in a click too; selecting on it would pick
// whatever the new line happens to overlap.
if (this.armedTool) return;
if (!this.onChartClick) return;
const 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 => {
this.scheduleContextLabels();
if (!param.point || !param.time) {
this.tooltip.hidden = true;
return;
}
this.updateLineTooltip(param);
});
requestAnimationFrame(() => this.observePlotCanvas());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments();
this.renderContextLabels();
});
}
setBars(bars) {
this.bars = bars;
const candleData = bars.map(this.toCandle);
this.candles.setData(candleData);
this.volume.setData(bars.map(ConfluenceChart.toVolume));
this.futureSpace.setData(this.futureTimes(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);
}
requestAnimationFrame(() => this.renderAnchorHandles());
// The bar grid just changed underneath every pinned comment.
this.renderComments();
this.renderContextLabels();
this.renderTrendlineProjections();
}
/**
* 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 });
}
return slots;
}
updateBar(bar) {
// update() throws on anything older than the series' last point, which
// takes the whole app down rather than dropping one stale bar. Ticks made
// this reachable often enough to matter, so refuse it here as well as at
// the source: a bar behind the last one has nothing to contribute.
const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return;
this.candles.update(this.toCandle(bar));
this.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);
this.renderAnchorHandles();
this.renderContextLabels();
this.renderTrendlineProjections();
}
setComments(comments) {
this.comments = comments;
this.renderComments();
}
setCommentHandlers({ onToggle, onMove, onSelect }) {
this.onCommentToggle = onToggle;
this.onCommentMove = onMove;
this.onCommentSelect = onSelect;
}
// 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);
} else {
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
}
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 clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 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 };
if (anchorT < this.bars[0].t) return { x: null, before: true, after: false };
const lastBar = this.bars[this.bars.length - 1];
if (anchorT > lastBar.t) {
// Past the newest bar is not off the chart — the right edge carries
// whitespace, so ask the scale directly and only park it if it answers.
const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t);
return { x: beyond, before: false, after: beyond === null };
}
let lo = 0;
let hi = this.bars.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.bars[mid].t <= anchorT) lo = mid;
else hi = mid - 1;
}
return { x: this.chart.timeScale().timeToCoordinate(this.bars[lo].t), before: false, after: false };
}
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.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderContextLabels();
}
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;
}));
}
scheduleContextLabels() {
if (this.contextLabelFrame != null) return;
this.contextLabelFrame = requestAnimationFrame(() => {
this.contextLabelFrame = null;
this.renderContextLabels();
});
}
// --- tool arming and placement gestures ---------------------------------
setToolCompleteHandler(handler) { this.onToolComplete = handler; }
setSnap(enabled) { this.snapToBars = enabled; }
/**
* Arm a tool so the next chart gesture places one. Panning is suspended
* while armed, otherwise the drag that draws a line also drags the chart out
* from under it.
*/
armTool(tool) {
this.armedTool = tool;
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
this.hideSnapDot();
this.chartEl.classList.toggle('armed', Boolean(tool));
this.pendingAnchor = null;
this.clearGesture();
this.clearLinePreview();
}
/**
* 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.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments();
this.renderContextLabels();
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) : null;
const projectedTime = index == null ? null : this.timeAtIndex(index);
return { x, y, p: price, t: time == null ? projectedTime : Number(time) };
}
placementAtClient(clientX, clientY) {
const point = this.eventPoint({ clientX, clientY });
if (!point || !this.withinPlot(point)) return null;
return {
time: Math.round(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();
const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t);
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
if (lastLogical == null) return null;
return scale.logicalToCoordinate(Number(lastLogical) + index - (this.bars.length - 1));
}
coordinateAtTime(time) {
const last = this.bars[this.bars.length - 1];
if (!last) return null;
if (time <= last.t) {
const direct = this.chart.timeScale().timeToCoordinate(time);
if (direct != null) return direct;
}
return this.coordinateAtIndex(this.indexAt(time));
}
startToolGesture(event) {
if (!this.armedTool || event.button !== 0) return;
const point = this.eventPoint(event);
if (!point) return;
event.preventDefault();
this.gesture = { start: point, end: point };
this.chartEl.setPointerCapture?.(event.pointerId);
this.renderGesture();
}
moveToolGesture(event) {
if (!this.armedTool) return;
const point = this.eventPoint(event);
if (!point) return;
// 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 (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
}
finishToolGesture(event) {
if (!this.armedTool || !this.gesture) return;
const tool = this.armedTool;
const { start, end } = this.gesture;
this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId);
if (tool === '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 || a.t === b.t) 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) {
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 (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
}
clearGesture() {
this.gesture = null;
if (this.priceTag) this.priceTag.hidden = true;
// The pending anchor's rubber band must survive between the two clicks.
if (!this.pendingAnchor) this.clearLinePreview();
}
setClickHandler(handler) { this.onChartClick = handler; }
setLineChangeHandler(handler) { this.onLineChange = handler; }
setLineEndHandler(handler) { this.onLineEnd = handler; }
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);
}
setLinePreview(start, end, color, lineWidth) {
if (start.t === end.t) return;
const x1 = this.chart.timeScale().timeToCoordinate(start.t);
const x2 = this.chart.timeScale().timeToCoordinate(end.t);
const y1 = this.candles.priceToCoordinate(start.p);
const y2 = this.candles.priceToCoordinate(end.p);
if ([x1, x2, y1, y2].some(value => value == null)) return;
this.previewLine.removeAttribute('hidden');
this.previewLine.setAttribute('x1', x1);
this.previewLine.setAttribute('y1', y1);
this.previewLine.setAttribute('x2', x2);
this.previewLine.setAttribute('y2', y2);
this.previewLine.setAttribute('stroke', color);
this.previewLine.setAttribute('stroke-width', lineWidth);
this.previewLine.setAttribute('stroke-dasharray', '6 4');
}
clearLinePreview() {
if (this.previewLine) this.previewLine.setAttribute('hidden', '');
}
updateLineTooltip(param) {
const id = this.hitTest(param);
const line = this.levels.find(level => level.id === id);
this.tooltip.hidden = !line;
if (!line) return;
this.tooltip.textContent = `#${line.number} ${line.label}`;
this.tooltip.style.left = `${param.point.x + 12}px`;
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`;
}
hitTest(param) {
let best = null;
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);
if (endIndex <= startIndex) continue;
const start = {
x: this.coordinateAtIndex(startIndex),
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)),
};
const end = {
x: this.coordinateAtIndex(endIndex),
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, endIndex)),
};
if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue;
if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue;
const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x);
const distance = Math.abs(lineY - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
}
return best?.id || null;
}
/**
* Fractional index of a timestamp in the bar series. Mirrors index_at() in
* app/analysis/bar_space.py — the chart spaces bars evenly however much time
* separates them, so this is the space a straight line is straight in.
*/
indexAt(time) {
const bars = this.bars;
if (bars.length < 2) return 0;
if (time <= bars[0].t) {
const step = bars[1].t - bars[0].t;
return step ? (time - bars[0].t) / step : 0;
}
const last = bars.length - 1;
if (time >= bars[last].t) {
const step = bars[last].t - bars[last - 1].t;
return last + (step ? (time - bars[last].t) / step : 0);
}
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (bars[mid].t <= time) lo = mid; else hi = mid;
}
const span = bars[hi].t - bars[lo].t;
return lo + (span ? (time - bars[lo].t) / span : 0);
}
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) {
return Math.round(bars[last].t + (index - last) * (bars[last].t - bars[last - 1].t));
}
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) {
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 [];
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);
return indexes.map(index => ({
time: this.timeAtIndex(index),
value: this.linePriceAtIndex(level, index),
}));
}
renderTrendlineProjections() {
if (!this.projectionLayer || !this.bars.length) return;
const visible = this.visibleBarIndexes();
if (!visible) return;
const lastBar = this.bars.length - 1;
const lines = this.levels
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
.map(level => {
const start = Math.max(lastBar, this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
const end = Math.min(naturalEnd, visible.to);
if (end <= start) return null;
const x1 = this.coordinateAtIndex(start);
const x2 = this.coordinateAtIndex(end);
const y1 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, start));
const y2 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, end));
if ([x1, x2, y1, y2].some(value => value == null)) return null;
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
node.dataset.lineId = level.id;
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;
})
.filter(Boolean);
this.projectionLayer.replaceChildren(...lines);
}
lineData(level) {
// Interpolated across bars, matching what the server scores the level at.
// Doing it per second instead kicked the line upward at every session gap,
// because a one-hour halt is one bar wide but an hour of slope.
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
const times = [level.anchor_t, level.last_t];
const cutoffIndex = level.cutoff_t == null
? -1
: this.bars.findIndex(bar => bar.t >= level.cutoff_t);
const target = cutoffIndex >= 0 ? this.bars[cutoffIndex] : this.bars[this.bars.length - 1];
if (target && target.t > level.last_t) times.push(target.t);
const startIndex = this.indexAt(level.anchor_t);
const span = this.indexAt(level.last_t) - startIndex;
return times
.filter((time, index) => index === 0 || time !== times[index - 1])
.map(time => ({
time,
value: span
? level.anchor_p + (endPrice - level.anchor_p) * (this.indexAt(time) - startIndex) / span
: level.anchor_p,
}));
}
renderAnchorHandles() {
const level = this.levels.find(
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
);
if (!level || !this.anchorHandles.length) {
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
this.lineHitTarget?.setAttribute('hidden', '');
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 hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
const hitIndexes = [this.indexAt(level.anchor_t), hitEnd];
const hitPoints = hitIndexes.map(index => ({
x: index == null ? null : this.coordinateAtIndex(index),
y: index == null ? null : this.candles.priceToCoordinate(this.linePriceAtIndex(level, index)),
}));
if (hitPoints.some(point => point.x == null || point.y == null)) {
this.lineHitTarget.setAttribute('hidden', '');
} else {
this.lineHitTarget.removeAttribute('hidden');
this.lineHitTarget.setAttribute('x1', hitPoints[0].x);
this.lineHitTarget.setAttribute('y1', hitPoints[0].y);
this.lineHitTarget.setAttribute('x2', hitPoints[1].x);
this.lineHitTarget.setAttribute('y2', hitPoints[1].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 || !point || point.t == null) return;
event.preventDefault();
event.stopPropagation();
this.draggingLine = {
id: level.id,
start: point,
original: { ...level },
moved: false,
};
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 indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t));
const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
const lastT = 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 = 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.renderTrendlineProjections();
}
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;
if (moved && level && this.onLineChange) this.onLineChange({ ...level });
}
startAnchorDrag(event, anchor) {
event.preventDefault();
event.stopPropagation();
this.draggingAnchor = { id: this.selectedLineId, anchor };
event.currentTarget.setPointerCapture?.(event.pointerId);
}
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.snapPoint(point);
if (snapped.t == null || snapped.p == null) return;
const time = snapped.t;
const price = snapped.p;
if (this.snapToBars) this.showSnapDot(snapped, point);
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.renderTrendlineProjections();
}
finishAnchorDrag(event) {
if (!this.draggingAnchor) return;
event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
this.draggingAnchor = null;
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.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.renderTrendlineProjections();
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(39,130,92,.38)' : 'rgba(189,69,69,.38)',
};
}
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.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame);
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.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.tfColors = {
'1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b',
'30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073',
};
// VWAP and the prior-day levels are both stamped 1d, so without their own
// colours they would be indistinguishable from the daily moving averages.
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
ConfluenceChart.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;