chart/static/chart.js
2026-08-14 01:12:20 -05:00

1794 lines
76 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.describeDrawingColor = 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: '#20b8a6', downColor: '#ef5b3f', borderVisible: true,
borderUpColor: '#087f76', borderDownColor: '#b9342d',
wickUpColor: '#087f76', wickDownColor: '#b9342d',
});
// 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));
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 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; }
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);
}
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;
const color = this.describeDrawingColor?.(line.color);
this.tooltip.textContent = [
`#${line.number} ${line.label}`,
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);
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',
);
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 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();
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);
}
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.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(32,184,166,.38)' : 'rgba(239,91,63,.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;