A comment is a ManualLine with kind="comment", so it inherits persistence, the shared drawing-number sequence, the list, filtering and deletion rather than needing a parallel set of endpoints. The rule that must never bend is that ManualLineStore.levels() excludes them: a comment reaching the level list would join a confluence cluster and push a phone notification about a piece of text. It is created with armed=False, and PATCH returns to_dict() rather than to_level() for a comment, so nothing is ever handed a level-shaped comment. Three tests cover the exclusion, the shared numbering and the kind derived for drawings saved before comments existed. Pinned comments carry anchor_t and anchor_p and travel with the chart; floating ones carry x and y as fractions of the pane, hold their place through any zoom and can be dragged. They render as DOM rather than canvas because they hold arbitrary text, collapse to a numbered dot, and a floating one must ignore the time scale entirely. A pinned comment scrolled out of view parks on the edge it left, pointing back toward itself, so it never simply disappears. Lines & levels becomes Drawings, filtered by type and by text. The text match covers the label, the kind and the #number, so "comment", "cpi" and "7" all narrow the list, and Delete acts on whatever the filter shows — which is what makes deleting by type or by string one button. Verified in a browser: placing a comment renders it at the click, clicking it collapses it, scrolling away parks it on the edge, a floating one holds its pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type, 1 by text and 0 for a miss, with no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
918 lines
38 KiB
JavaScript
918 lines
38 KiB
JavaScript
class ConfluenceChart {
|
|
constructor() {
|
|
this.chart = null;
|
|
this.candles = null;
|
|
this.resizeObserver = null;
|
|
this.levelSeries = new Map();
|
|
this.priceLines = new Map();
|
|
this.previewLine = null;
|
|
this.bars = [];
|
|
this.levels = [];
|
|
this.onChartClick = null;
|
|
this.tooltip = null;
|
|
this.chartEl = null;
|
|
this.clickListener = null;
|
|
this.selectedLineId = null;
|
|
this.anchorHandles = [];
|
|
this.draggingAnchor = null;
|
|
this.onLineChange = null;
|
|
this.anchorMoveListener = null;
|
|
this.anchorUpListener = null;
|
|
this.contextMenu = null;
|
|
this.contextCutoff = null;
|
|
this.contextListener = null;
|
|
this.onLineEnd = null;
|
|
// Tool arming: the sidebar decides what the next chart gesture creates.
|
|
this.armedTool = null;
|
|
this.gesture = null;
|
|
this.pendingAnchor = null;
|
|
this.onToolComplete = null;
|
|
this.snapToBars = true;
|
|
this.priceTag = null;
|
|
this.toolDownListener = null;
|
|
this.toolMoveListener = null;
|
|
this.toolUpListener = null;
|
|
this.pendingView = null;
|
|
this.comments = [];
|
|
this.commentNodes = new Map();
|
|
this.commentLayer = null;
|
|
this.onCommentToggle = null;
|
|
this.onCommentMove = null;
|
|
}
|
|
|
|
static TICK = 0.25;
|
|
|
|
static snapToTick(price) {
|
|
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
|
}
|
|
|
|
create(el) {
|
|
this.chartEl = el;
|
|
this.chart = LightweightCharts.createChart(el, {
|
|
autoSize: true,
|
|
layout: {
|
|
background: { color: getComputedStyle(document.documentElement).getPropertyValue('--chart-bg').trim() },
|
|
textColor: getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(),
|
|
},
|
|
grid: {
|
|
vertLines: { color: 'rgba(128,128,128,.10)' },
|
|
horzLines: { color: 'rgba(128,128,128,.10)' },
|
|
},
|
|
timeScale: {
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
// The library is timezone-agnostic: it reads epoch seconds as UTC and
|
|
// labels them as UTC. Bars stay in UTC — that convention holds
|
|
// everywhere in this codebase, and shifting the values themselves would
|
|
// put every trendline anchor, hit test and alert an offset out. Only
|
|
// the label is localised, via the browser's own zone.
|
|
tickMarkFormatter: (time, tickMarkType) => {
|
|
const at = new Date(time * 1000);
|
|
if (tickMarkType === LightweightCharts.TickMarkType.Year) return String(at.getFullYear());
|
|
if (tickMarkType === LightweightCharts.TickMarkType.Month) {
|
|
return at.toLocaleString(undefined, { month: 'short' });
|
|
}
|
|
if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) return String(at.getDate());
|
|
return at.toLocaleTimeString(undefined, {
|
|
hour12: false, hour: '2-digit', minute: '2-digit',
|
|
...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds
|
|
? { second: '2-digit' } : {}),
|
|
});
|
|
},
|
|
},
|
|
localization: {
|
|
// The crosshair readout, which would otherwise disagree with the axis.
|
|
timeFormatter: time => new Date(time * 1000).toLocaleString(undefined, {
|
|
hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
}),
|
|
},
|
|
rightPriceScale: { borderVisible: false },
|
|
// Daily context lives on the left, intraday on the right.
|
|
leftPriceScale: { visible: true, borderVisible: false },
|
|
});
|
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
|
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
|
borderUpColor: '#1d6849', borderDownColor: '#963737',
|
|
wickUpColor: '#1d6849', wickDownColor: '#963737',
|
|
});
|
|
// A price scale derives its range from the series on it, so levels moved to
|
|
// the left would be drawn against a different range and sit at the wrong
|
|
// height. This transparent copy of the candles gives the left scale exactly
|
|
// the same input as the right, which keeps one price at one y.
|
|
this.leftMirror = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
|
priceScaleId: 'left',
|
|
upColor: 'transparent', downColor: 'transparent', borderVisible: false,
|
|
wickUpColor: 'transparent', wickDownColor: 'transparent',
|
|
lastValueVisible: false, priceLineVisible: false,
|
|
});
|
|
// 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.82, bottom: 0 },
|
|
visible: false,
|
|
});
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
|
});
|
|
this.resizeObserver.observe(el);
|
|
this.tooltip = document.createElement('div');
|
|
this.tooltip.className = 'chart-tooltip';
|
|
el.appendChild(this.tooltip);
|
|
const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
preview.classList.add('chart-preview');
|
|
preview.setAttribute('aria-hidden', 'true');
|
|
this.previewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
this.previewLine.setAttribute('hidden', '');
|
|
preview.appendChild(this.previewLine);
|
|
el.appendChild(preview);
|
|
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
handles.classList.add('chart-handles');
|
|
handles.setAttribute('aria-hidden', 'true');
|
|
for (const anchor of ['start', 'end']) {
|
|
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
handle.classList.add('chart-anchor');
|
|
handle.dataset.anchor = anchor;
|
|
handle.setAttribute('r', '6');
|
|
handle.setAttribute('hidden', '');
|
|
handle.addEventListener('pointerdown', event => this.startAnchorDrag(event, anchor));
|
|
handle.addEventListener('click', event => event.stopPropagation());
|
|
handles.appendChild(handle);
|
|
this.anchorHandles.push(handle);
|
|
}
|
|
el.appendChild(handles);
|
|
this.contextMenu = document.createElement('div');
|
|
this.contextMenu.className = 'chart-context-menu';
|
|
this.contextMenu.hidden = true;
|
|
const endHere = document.createElement('button');
|
|
endHere.type = 'button';
|
|
endHere.textContent = 'End trendline here';
|
|
endHere.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
this.endSelectedLineHere();
|
|
});
|
|
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
|
this.contextMenu.appendChild(endHere);
|
|
el.appendChild(this.contextMenu);
|
|
this.priceTag = document.createElement('div');
|
|
this.priceTag.className = 'chart-price-tag';
|
|
this.priceTag.hidden = true;
|
|
el.appendChild(this.priceTag);
|
|
this.commentLayer = document.createElement('div');
|
|
this.commentLayer.className = 'chart-comments';
|
|
el.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.contextListener = event => this.showContextMenu(event);
|
|
el.addEventListener('contextmenu', this.contextListener);
|
|
this.clickListener = event => {
|
|
this.hideContextMenu();
|
|
// A placement gesture ends in a click too; selecting on it would pick
|
|
// whatever the new line happens to overlap.
|
|
if (this.armedTool) return;
|
|
if (!this.onChartClick) return;
|
|
const bounds = el.getBoundingClientRect();
|
|
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
|
|
const time = this.chart.timeScale().coordinateToTime(point.x);
|
|
if (time != null) this.onChartClick({ point, time });
|
|
};
|
|
el.addEventListener('click', this.clickListener);
|
|
this.chart.subscribeCrosshairMove(param => {
|
|
if (!param.point || !param.time) {
|
|
this.tooltip.hidden = true;
|
|
return;
|
|
}
|
|
this.updateLineTooltip(param);
|
|
});
|
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
|
|
this.renderAnchorHandles();
|
|
this.renderComments();
|
|
});
|
|
}
|
|
|
|
setBars(bars) {
|
|
this.bars = bars;
|
|
const candleData = bars.map(this.toCandle);
|
|
this.candles.setData(candleData);
|
|
this.leftMirror.setData(candleData);
|
|
this.volume.setData(bars.map(ConfluenceChart.toVolume));
|
|
// Anchored by time, not by logical index. A logical index addresses the
|
|
// chart's *shared* scale — the union of every series' time points — not
|
|
// this array. The daily MAs land straight after with hundreds of points
|
|
// pre-dating the 1m window, and prepending them shifts every logical index
|
|
// by that count, silently dragging the view ten hours off the live edge.
|
|
// A time range names the instant, so later series cannot move it.
|
|
if (bars.length) {
|
|
const last = bars[bars.length - 1];
|
|
// Keep the old five bars of right-hand breathing room, in seconds.
|
|
const step = bars.length > 1 ? last.t - bars[bars.length - 2].t : 60;
|
|
this.pendingView = {
|
|
from: bars[Math.max(0, bars.length - 160)].t,
|
|
to: last.t + step * 5,
|
|
};
|
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
|
}
|
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
|
}
|
|
|
|
updateBar(bar) {
|
|
// update() throws on anything older than the series' last point, which
|
|
// takes the whole app down rather than dropping one stale bar. Ticks made
|
|
// this reachable often enough to matter, so refuse it here as well as at
|
|
// the source: a bar behind the last one has nothing to contribute.
|
|
const last = this.bars[this.bars.length - 1];
|
|
if (last && bar.t < last.t) return;
|
|
this.candles.update(this.toCandle(bar));
|
|
this.leftMirror.update(this.toCandle(bar));
|
|
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();
|
|
}
|
|
|
|
setComments(comments) {
|
|
this.comments = comments;
|
|
this.renderComments();
|
|
}
|
|
|
|
setCommentHandlers({ onToggle, onMove }) {
|
|
this.onCommentToggle = onToggle;
|
|
this.onCommentMove = onMove;
|
|
}
|
|
|
|
// 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.classList.toggle('collapsed', !!comment.collapsed);
|
|
node.root.classList.toggle('floating', !comment.pinned);
|
|
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
|
|
node.root.style.borderColor = comment.color;
|
|
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 x = this.chart.timeScale().timeToCoordinate(comment.anchor_t);
|
|
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 = x === null || x < 0;
|
|
const offRight = 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)) {
|
|
node.root.remove();
|
|
this.commentNodes.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
buildCommentNode(comment) {
|
|
const root = document.createElement('div');
|
|
root.className = 'chart-comment';
|
|
const label = document.createElement('span');
|
|
label.className = 'chart-comment-text';
|
|
root.appendChild(label);
|
|
const node = { root, label, comment };
|
|
root.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
if (this.onCommentToggle) this.onCommentToggle(node.comment);
|
|
});
|
|
// Floating comments are dragged; pinned ones belong to their bar.
|
|
root.addEventListener('pointerdown', event => {
|
|
if (node.comment.pinned) return;
|
|
event.stopPropagation();
|
|
const bounds = this.chartEl.getBoundingClientRect();
|
|
const move = moveEvent => {
|
|
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - 8)}px`;
|
|
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - 8)}px`;
|
|
};
|
|
const up = upEvent => {
|
|
window.removeEventListener('pointermove', move);
|
|
window.removeEventListener('pointerup', up);
|
|
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, clamp(x), 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);
|
|
}
|
|
|
|
// Flat levels are drawn as price lines rather than two-point series: they
|
|
// span the whole chart regardless of scroll and get a price-axis label.
|
|
syncPriceLines(levels) {
|
|
const flat = levels.filter(level => ConfluenceChart.isFlat(level) && !level.hidden);
|
|
const wanted = new Set(flat.map(level => level.id));
|
|
for (const [id, entry] of this.priceLines) {
|
|
if (!wanted.has(id)) {
|
|
entry.host.removePriceLine(entry.line);
|
|
this.priceLines.delete(id);
|
|
}
|
|
}
|
|
for (const level of flat) {
|
|
const options = {
|
|
price: level.anchor_p,
|
|
color: ConfluenceChart.levelColor(level),
|
|
lineWidth: level.line_width || 1,
|
|
lineStyle: LightweightCharts.LineStyle.Dashed,
|
|
axisLabelVisible: true,
|
|
title: level.label,
|
|
};
|
|
const existing = this.priceLines.get(level.id);
|
|
if (existing) existing.line.applyOptions(options);
|
|
else {
|
|
// Prior-day levels are daily context and move to the left scale; a
|
|
// hand-drawn price level is intraday and keeps the right, which is the
|
|
// side being kept clear for it.
|
|
const host = level.kind === 'horizontal' ? this.leftMirror : this.candles;
|
|
this.priceLines.set(level.id, { host, line: host.createPriceLine(options) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Level points are sampled on their own timeframe — VWAP every minute, the
|
|
// daily averages once a session — and every distinct timestamp claims its own
|
|
// slot on the chart's shared scale. Left raw, a minute-resolution VWAP spread
|
|
// 160 hourly candles across 908 slots and drew them as unreadable slivers.
|
|
// Snapping onto the candle grid preserves the line's shape while keeping the
|
|
// scale one slot per candle, which is what makes the bars their proper width.
|
|
snapPointsToBars(points) {
|
|
if (!this.bars.length || !points.length) return points;
|
|
const times = this.bars.map(bar => bar.t);
|
|
const last = times[times.length - 1];
|
|
const byTime = new Map();
|
|
for (const point of points) {
|
|
// Clamped, not passed through: on a daily chart every one of VWAP's ~760
|
|
// minute points falls after the last candle's session open, and letting
|
|
// them keep their own times put all 760 back on the scale. Projection to
|
|
// the right of the last bar is handled by the caller instead.
|
|
if (point.time >= last) {
|
|
byTime.set(last, point.value);
|
|
continue;
|
|
}
|
|
let lo = 0;
|
|
let hi = times.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (times[mid] < point.time) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
// Points older than the window collapse onto the first candle; the newest
|
|
// of them wins, which is the value in force when the window opens.
|
|
byTime.set(times[lo], point.value);
|
|
}
|
|
return [...byTime.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([time, value]) => ({ time, value }));
|
|
}
|
|
|
|
syncLevels(levels) {
|
|
this.levels = levels;
|
|
this.syncPriceLines(levels);
|
|
const drawn = levels.filter(level => !ConfluenceChart.isFlat(level));
|
|
const wanted = new Set(drawn.filter(level => !level.hidden).map(level => level.id));
|
|
for (const [id, entry] of this.levelSeries) {
|
|
if (!wanted.has(id)) {
|
|
this.chart.removeSeries(entry.series);
|
|
this.levelSeries.delete(id);
|
|
}
|
|
}
|
|
for (const level of drawn) {
|
|
if (level.hidden) continue;
|
|
let entry = this.levelSeries.get(level.id);
|
|
// Both trace a series of points, but a higher-timeframe average holds its
|
|
// value between closes while VWAP moves continuously.
|
|
const hasPoints = level.kind === 'ma' || level.kind === 'vwap';
|
|
const isMa = level.kind === 'ma';
|
|
const options = {
|
|
color: ConfluenceChart.levelColor(level),
|
|
lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1),
|
|
lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple,
|
|
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
|
|
priceLineVisible: false,
|
|
lastValueVisible: level.cutoff_t == null,
|
|
title: level.label,
|
|
// Overlays outside the visible price range must not flatten the candles.
|
|
autoscaleInfoProvider: () => null,
|
|
};
|
|
if (!entry) {
|
|
// Daily averages and session VWAP are daily/session context, so their
|
|
// last-value labels belong on the left. priceScaleId is fixed at
|
|
// creation, which is why it is not in the options reapplied below.
|
|
const scale = (isMa || level.kind === 'vwap') ? 'left' : 'right';
|
|
entry = {
|
|
series: this.chart.addSeries(LightweightCharts.LineSeries,
|
|
{ ...options, priceScaleId: scale }),
|
|
};
|
|
this.levelSeries.set(level.id, entry);
|
|
} else {
|
|
entry.series.applyOptions(options);
|
|
}
|
|
let data;
|
|
if (hasPoints) {
|
|
data = this.snapPointsToBars((level.points || []).map(([time, value]) => ({ time, value })));
|
|
const latestTime = this.bars[this.bars.length - 1]?.t;
|
|
const latestValue = data[data.length - 1]?.value;
|
|
if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) {
|
|
data.push({ time: latestTime, value: latestValue });
|
|
}
|
|
} else {
|
|
data = this.lineData(level);
|
|
}
|
|
entry.series.setData(data);
|
|
}
|
|
// Re-anchor once, after the level series have reshaped the scale. setBars
|
|
// runs before them, so the width it asked for was derived from the previous
|
|
// timeframe's point density and the chart holds that width as new series
|
|
// arrive. Consumed rather than reapplied every time: VWAP resyncs a level
|
|
// every minute, and re-anchoring on each would yank the view back from
|
|
// wherever the user had panned it.
|
|
if (this.pendingView) {
|
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
|
this.pendingView = null;
|
|
}
|
|
this.renderAnchorHandles();
|
|
}
|
|
|
|
// --- tool arming and placement gestures ---------------------------------
|
|
|
|
setToolCompleteHandler(handler) { this.onToolComplete = handler; }
|
|
|
|
setSnap(enabled) { this.snapToBars = enabled; }
|
|
|
|
/**
|
|
* Arm a tool so the next chart gesture places one. Panning is suspended
|
|
* while armed, otherwise the drag that draws a line also drags the chart out
|
|
* from under it.
|
|
*/
|
|
armTool(tool) {
|
|
this.armedTool = tool;
|
|
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
|
|
this.chartEl.classList.toggle('armed', Boolean(tool));
|
|
this.pendingAnchor = null;
|
|
this.clearGesture();
|
|
this.clearLinePreview();
|
|
}
|
|
|
|
eventPoint(event) {
|
|
const bounds = this.chartEl.getBoundingClientRect();
|
|
const x = event.clientX - bounds.left;
|
|
const y = event.clientY - bounds.top;
|
|
const price = this.candles.coordinateToPrice(y);
|
|
if (price == null) return null;
|
|
const time = this.chart.timeScale().coordinateToTime(x);
|
|
return { x, y, p: price, t: time == null ? null : Number(time) };
|
|
}
|
|
|
|
startToolGesture(event) {
|
|
if (!this.armedTool || event.button !== 0) return;
|
|
const point = this.eventPoint(event);
|
|
if (!point) return;
|
|
event.preventDefault();
|
|
this.gesture = { start: point, end: point };
|
|
this.chartEl.setPointerCapture?.(event.pointerId);
|
|
this.renderGesture();
|
|
}
|
|
|
|
moveToolGesture(event) {
|
|
if (!this.armedTool) return;
|
|
const point = this.eventPoint(event);
|
|
if (!point) return;
|
|
if (this.gesture) {
|
|
this.gesture.end = point;
|
|
this.renderGesture();
|
|
return;
|
|
}
|
|
// Waiting on the second click: rubber-band from the placed anchor.
|
|
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
|
}
|
|
|
|
renderPending(a, b) {
|
|
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
|
|
}
|
|
|
|
finishToolGesture(event) {
|
|
if (!this.armedTool || !this.gesture) return;
|
|
const tool = this.armedTool;
|
|
const { start, end } = this.gesture;
|
|
this.clearGesture();
|
|
this.chartEl.releasePointerCapture?.(event.pointerId);
|
|
|
|
if (tool === 'comment') {
|
|
// 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.
|
|
// A placed anchor wins over this click's own drag. Recomputing `dragged`
|
|
// here meant a few pixels of movement while pressing the second click was
|
|
// read as a fresh press-drag-release, which threw the anchor away and
|
|
// started the line at the second click instead — the preview had been
|
|
// rubber-banding from the real anchor all along, so the finished line
|
|
// jumped away from where it had just been drawn.
|
|
const dragged = !this.pendingAnchor
|
|
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
|
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
|
|
const b = this.snapPoint(end);
|
|
if (!dragged && !this.pendingAnchor) {
|
|
this.pendingAnchor = b;
|
|
this.renderPending(b, b);
|
|
return;
|
|
}
|
|
this.pendingAnchor = null;
|
|
this.clearLinePreview();
|
|
if (a.t == null || b.t == null || a.t === b.t) return;
|
|
this.onToolComplete?.({ tool, start: a, end: b });
|
|
}
|
|
|
|
/**
|
|
* Trendline anchors snap to bar extremes; levels snap to the tick grid.
|
|
*
|
|
* 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.
|
|
*/
|
|
snapPoint(point) {
|
|
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
|
|
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
|
|
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
|
if (point.y == null) return base;
|
|
const nearest = this.bars.reduce(
|
|
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
|
);
|
|
const candidates = [
|
|
{ p: nearest.h, side: 'resistance' },
|
|
{ p: nearest.l, side: 'support' },
|
|
];
|
|
const snapped = candidates
|
|
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
|
|
.sort((a, b) => a.distance - b.distance)[0];
|
|
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; }
|
|
|
|
setSelectedLine(id) {
|
|
this.selectedLineId = id;
|
|
this.hideContextMenu();
|
|
this.renderAnchorHandles();
|
|
}
|
|
|
|
setLinePreview(start, end, color, lineWidth) {
|
|
if (start.t === end.t) return;
|
|
const x1 = this.chart.timeScale().timeToCoordinate(start.t);
|
|
const x2 = this.chart.timeScale().timeToCoordinate(end.t);
|
|
const y1 = this.candles.priceToCoordinate(start.p);
|
|
const y2 = this.candles.priceToCoordinate(end.p);
|
|
if ([x1, x2, y1, y2].some(value => value == null)) return;
|
|
this.previewLine.removeAttribute('hidden');
|
|
this.previewLine.setAttribute('x1', x1);
|
|
this.previewLine.setAttribute('y1', y1);
|
|
this.previewLine.setAttribute('x2', x2);
|
|
this.previewLine.setAttribute('y2', y2);
|
|
this.previewLine.setAttribute('stroke', color);
|
|
this.previewLine.setAttribute('stroke-width', lineWidth);
|
|
this.previewLine.setAttribute('stroke-dasharray', '6 4');
|
|
}
|
|
|
|
clearLinePreview() {
|
|
if (this.previewLine) this.previewLine.setAttribute('hidden', '');
|
|
}
|
|
|
|
updateLineTooltip(param) {
|
|
const id = this.hitTest(param);
|
|
const line = this.levels.find(level => level.id === id);
|
|
this.tooltip.hidden = !line;
|
|
if (!line) return;
|
|
this.tooltip.textContent = `#${line.number} ${line.label}`;
|
|
this.tooltip.style.left = `${param.point.x + 12}px`;
|
|
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`;
|
|
}
|
|
|
|
hitTest(param) {
|
|
let best = null;
|
|
// Flat levels draw as price lines, which have no series geometry to hit —
|
|
// they are selected and removed from the sidebar list instead.
|
|
for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) {
|
|
const points = this.lineData(level).map(point => ({
|
|
x: this.chart.timeScale().timeToCoordinate(point.time),
|
|
y: this.candles.priceToCoordinate(point.value),
|
|
}));
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const [start, end] = [points[index - 1], points[index]];
|
|
if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue;
|
|
if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue;
|
|
const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x);
|
|
const distance = Math.abs(lineY - param.point.y);
|
|
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
|
|
}
|
|
}
|
|
return best?.id || null;
|
|
}
|
|
|
|
/**
|
|
* Fractional index of a timestamp in the bar series. Mirrors index_at() in
|
|
* app/analysis/bar_space.py — the chart spaces bars evenly however much time
|
|
* separates them, so this is the space a straight line is straight in.
|
|
*/
|
|
indexAt(time) {
|
|
const bars = this.bars;
|
|
if (bars.length < 2) return 0;
|
|
if (time <= bars[0].t) {
|
|
const step = bars[1].t - bars[0].t;
|
|
return step ? (time - bars[0].t) / step : 0;
|
|
}
|
|
const last = bars.length - 1;
|
|
if (time >= bars[last].t) {
|
|
const step = bars[last].t - bars[last - 1].t;
|
|
return last + (step ? (time - bars[last].t) / step : 0);
|
|
}
|
|
let lo = 0;
|
|
let hi = last;
|
|
while (hi - lo > 1) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (bars[mid].t <= time) lo = mid; else hi = mid;
|
|
}
|
|
const span = bars[hi].t - bars[lo].t;
|
|
return lo + (span ? (time - bars[lo].t) / span : 0);
|
|
}
|
|
|
|
lineData(level) {
|
|
// Interpolated across bars, matching what the server scores the level at.
|
|
// Doing it per second instead kicked the line upward at every session gap,
|
|
// because a one-hour halt is one bar wide but an hour of slope.
|
|
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
|
const times = [level.anchor_t, level.last_t];
|
|
const cutoffIndex = level.cutoff_t == null
|
|
? -1
|
|
: this.bars.findIndex(bar => bar.t >= level.cutoff_t);
|
|
const target = cutoffIndex >= 0 ? this.bars[cutoffIndex] : this.bars[this.bars.length - 1];
|
|
if (target && target.t > level.last_t) times.push(target.t);
|
|
|
|
const startIndex = this.indexAt(level.anchor_t);
|
|
const span = this.indexAt(level.last_t) - startIndex;
|
|
return times
|
|
.filter((time, index) => index === 0 || time !== times[index - 1])
|
|
.map(time => ({
|
|
time,
|
|
value: span
|
|
? level.anchor_p + (endPrice - level.anchor_p) * (this.indexAt(time) - startIndex) / span
|
|
: level.anchor_p,
|
|
}));
|
|
}
|
|
|
|
renderAnchorHandles() {
|
|
const level = this.levels.find(
|
|
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
|
|
);
|
|
if (!level || !this.anchorHandles.length) {
|
|
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
|
return;
|
|
}
|
|
const points = this.lineData(level).slice(0, 2);
|
|
points.forEach((point, index) => {
|
|
const x = this.chart.timeScale().timeToCoordinate(point.time);
|
|
const y = this.candles.priceToCoordinate(point.value);
|
|
const handle = this.anchorHandles[index];
|
|
if (x == null || y == null) {
|
|
handle.setAttribute('hidden', '');
|
|
return;
|
|
}
|
|
handle.removeAttribute('hidden');
|
|
handle.setAttribute('cx', x);
|
|
handle.setAttribute('cy', y);
|
|
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
|
|
});
|
|
}
|
|
|
|
startAnchorDrag(event, anchor) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.draggingAnchor = { id: this.selectedLineId, anchor };
|
|
}
|
|
|
|
moveAnchor(event) {
|
|
if (!this.draggingAnchor || !this.chartEl) return;
|
|
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
|
|
if (!level || !this.bars.length) return;
|
|
const bounds = this.chartEl.getBoundingClientRect();
|
|
const x = Math.max(0, Math.min(bounds.width, event.clientX - bounds.left));
|
|
const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top));
|
|
const rawTime = this.chart.timeScale().coordinateToTime(x);
|
|
const price = this.candles.coordinateToPrice(y);
|
|
if (rawTime == null || price == null) return;
|
|
const time = this.bars.reduce((nearest, bar) =>
|
|
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest
|
|
).t;
|
|
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
|
if (this.draggingAnchor.anchor === 'start') {
|
|
if (time >= level.last_t) return;
|
|
level.anchor_t = time;
|
|
level.anchor_p = price;
|
|
level.slope = (secondPrice - price) / (level.last_t - time);
|
|
} else {
|
|
if (time <= level.anchor_t) return;
|
|
if (level.cutoff_t != null && time >= level.cutoff_t) return;
|
|
level.last_t = time;
|
|
level.slope = (price - level.anchor_p) / (time - level.anchor_t);
|
|
}
|
|
const entry = this.levelSeries.get(level.id);
|
|
if (entry) entry.series.setData(this.lineData(level));
|
|
this.renderAnchorHandles();
|
|
}
|
|
|
|
finishAnchorDrag(event) {
|
|
if (!this.draggingAnchor) return;
|
|
event.preventDefault();
|
|
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
|
|
this.draggingAnchor = null;
|
|
if (level && this.onLineChange) this.onLineChange({ ...level });
|
|
}
|
|
|
|
showContextMenu(event) {
|
|
const level = this.levels.find(
|
|
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
|
|
);
|
|
if (!level || !this.bars.length) return;
|
|
event.preventDefault();
|
|
const bounds = this.chartEl.getBoundingClientRect();
|
|
const x = event.clientX - bounds.left;
|
|
const rawTime = this.chart.timeScale().coordinateToTime(x);
|
|
if (rawTime == null) return;
|
|
const cutoff = this.bars.reduce((nearest, bar) =>
|
|
Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest
|
|
).t;
|
|
if (cutoff <= level.last_t) {
|
|
this.hideContextMenu();
|
|
return;
|
|
}
|
|
this.contextCutoff = cutoff;
|
|
this.contextMenu.hidden = false;
|
|
this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`;
|
|
this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`;
|
|
}
|
|
|
|
hideContextMenu() {
|
|
if (this.contextMenu) this.contextMenu.hidden = true;
|
|
this.contextCutoff = null;
|
|
}
|
|
|
|
endSelectedLineHere() {
|
|
const level = this.levels.find(value => value.id === this.selectedLineId);
|
|
if (!level || this.contextCutoff == null) return;
|
|
level.cutoff_t = this.contextCutoff;
|
|
const entry = this.levelSeries.get(level.id);
|
|
if (entry) entry.series.setData(this.lineData(level));
|
|
this.hideContextMenu();
|
|
if (this.onLineEnd) this.onLineEnd({ ...level });
|
|
}
|
|
|
|
// 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.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
|
|
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
|
|
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
|
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
|
|
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
|
|
if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener);
|
|
if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener);
|
|
if (this.chart) this.chart.remove();
|
|
}
|
|
}
|
|
|
|
ConfluenceChart.tfColors = {
|
|
'1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b',
|
|
'30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073',
|
|
};
|
|
|
|
// VWAP and the prior-day levels are both stamped 1d, so without their own
|
|
// colours they would be indistinguishable from the daily moving averages.
|
|
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
|
|
|
|
ConfluenceChart.levelColor = level =>
|
|
level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf];
|
|
|
|
window.ConfluenceChart = ConfluenceChart;
|