The trendline snap indicator was 66 pixels out, and so was every other overlay. Lightweight Charts reports coordinates from the plot area's origin. The chart element also contains the price scales, so enabling the left scale for the daily labels moved the plot 66px into the element — and each overlay positioned with left: against the element inherited that error twice over. The cursor's element-x was read as a plot-x, resolving a bar about 66px right of the pointer; the indicator was then drawn at that bar's plot-x interpreted as element-x, landing 66px left of where the bar is painted. Neither near the cursor nor near the bar, and scaling with zoom — two bars at 30m, a dozen at 1m — which is why it read as random rather than as an offset. Every diagnostic number agreed with itself the whole time, because dot_y, expected_y and bar_low_y all come from the same API and shared the same wrong origin. Instrumentation cannot see a systematic error in its own frame of reference; what found this was comparing canvas.width to element.clientWidth and getting 0.894. Overlays now live in one container positioned over the plot canvas and inherit plot coordinates untranslated — snap dot and label, comments, anchor handles, preview line, tooltip, price tag, context menu — and eventPoint subtracts the same offset so a pointer position and a chart coordinate mean the same thing. The container follows the plot on resize. Verified in page pixels rather than through the coordinate API: with the cursor placed 30px below a known bar's low, the dot lands 30px above the cursor, on that low, labelled 7789.00 L against a bar low of 7789. Device pixel ratio, viewport size, resize desynchronisation and the chart scrolling under the gesture were each measured and ruled out before this. The e2e helper computed expected times from element-relative x, the same mistake in the tests, and is corrected here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1187 lines
51 KiB
JavaScript
1187 lines
51 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.overlayLayer = null;
|
|
this.snapDot = null;
|
|
this.snapLabel = null;
|
|
this.snapLeader = null;
|
|
this.snapLeaderLine = null;
|
|
this.comments = [];
|
|
this.commentNodes = new Map();
|
|
this.commentLayer = null;
|
|
this.onCommentToggle = null;
|
|
this.onCommentMove = 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;
|
|
|
|
/**
|
|
* 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(),
|
|
},
|
|
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 },
|
|
// Never magnet. The default snaps the crosshair to the bar's *close*, so
|
|
// hovering beside a low reads a price several ticks away, and everything
|
|
// that snaps in this app snaps to extremes. The crosshair tracks the
|
|
// cursor; the snap dot says where an anchor would actually land.
|
|
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
|
});
|
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
|
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
|
borderUpColor: '#1d6849', borderDownColor: '#963737',
|
|
wickUpColor: '#1d6849', wickDownColor: '#963737',
|
|
});
|
|
// 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.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 } });
|
|
this.chart.priceScale('left').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
|
requestAnimationFrame(() => {
|
|
this.syncOverlayLayer();
|
|
this.renderAnchorHandles();
|
|
this.renderComments();
|
|
});
|
|
});
|
|
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. Enabling the
|
|
// left price scale moved the plot 66px right and silently displaced the
|
|
// snap indicator, the comments and the trendline handles by that much.
|
|
this.overlayLayer = document.createElement('div');
|
|
this.overlayLayer.className = 'chart-overlays';
|
|
el.appendChild(this.overlayLayer);
|
|
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);
|
|
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);
|
|
}
|
|
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.contextMenu.addEventListener('click', event => event.stopPropagation());
|
|
this.contextMenu.appendChild(endHere);
|
|
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.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);
|
|
});
|
|
requestAnimationFrame(() => this.syncOverlayLayer());
|
|
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());
|
|
// The bar grid just changed underneath every pinned comment.
|
|
this.renderComments();
|
|
}
|
|
|
|
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 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)) {
|
|
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 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.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, so
|
|
* a visible left price scale shifts the two apart. Overlays are positioned
|
|
* against the element, so every one of them must add this back. Enabling the
|
|
* left scale for the daily labels silently moved the snap indicator, the
|
|
* comments and the anchor handles 66px out of place.
|
|
*/
|
|
/** Put the overlay container exactly over the plot area. */
|
|
syncOverlayLayer() {
|
|
if (!this.overlayLayer || !this.chartEl) return;
|
|
const canvas = [...this.chartEl.querySelectorAll('canvas')]
|
|
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
|
|
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`;
|
|
}
|
|
|
|
plotOffsetX() {
|
|
if (!this.chartEl) return 0;
|
|
const canvas = [...this.chartEl.querySelectorAll('canvas')]
|
|
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
|
|
if (!canvas) return 0;
|
|
return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left;
|
|
}
|
|
|
|
eventPoint(event) {
|
|
const bounds = this.chartEl.getBoundingClientRect();
|
|
const x = event.clientX - bounds.left - this.plotOffsetX();
|
|
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;
|
|
// 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.chart.timeScale().timeToCoordinate(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`,
|
|
};
|
|
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') {
|
|
// 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) {
|
|
if (!this.chartEl) return true;
|
|
const width = this.chartEl.clientWidth;
|
|
const height = this.chartEl.clientHeight;
|
|
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;
|
|
// An already-snapped point carries no cursor position; return it untouched
|
|
// rather than measuring against undefined.
|
|
if (point.y == null) return base;
|
|
|
|
// Horizontal position chooses the bar, vertical position chooses which of
|
|
// its extremes. Nothing else — a nearest-in-2D search was tried and is
|
|
// wrong: sweeping along the bottom of the chart, whichever nearby bar had
|
|
// the lowest low won on total distance, so the dot skipped off the bar
|
|
// under the cursor entirely instead of tracing each bar's low in turn.
|
|
const nearest = this.bars.reduce(
|
|
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
|
);
|
|
const candidates = [
|
|
{ p: nearest.h, side: 'resistance' },
|
|
{ p: nearest.l, side: 'support' },
|
|
];
|
|
const snapped = candidates
|
|
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
|
|
.sort((a, b) => a.distance - b.distance)[0];
|
|
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
|
}
|
|
|
|
|
|
renderGesture() {
|
|
if (!this.gesture) return;
|
|
const { start, end } = this.gesture;
|
|
if (this.armedTool === 'level') {
|
|
const price = ConfluenceChart.snapToTick(end.p);
|
|
const y = this.candles.priceToCoordinate(price);
|
|
if (y == null) return;
|
|
this.previewLine.removeAttribute('hidden');
|
|
this.previewLine.setAttribute('x1', 0);
|
|
this.previewLine.setAttribute('y1', y);
|
|
this.previewLine.setAttribute('x2', this.chartEl.clientWidth);
|
|
this.previewLine.setAttribute('y2', y);
|
|
this.previewLine.setAttribute('stroke', '#e0a34a');
|
|
this.previewLine.setAttribute('stroke-width', 2);
|
|
this.previewLine.setAttribute('stroke-dasharray', '6 4');
|
|
this.priceTag.hidden = false;
|
|
this.priceTag.textContent = price.toFixed(2);
|
|
this.priceTag.style.top = `${y}px`;
|
|
this.priceTag.style.left = `${Math.min(end.x + 14, this.chartEl.clientWidth - 70)}px`;
|
|
return;
|
|
}
|
|
const a = this.snapPoint(start);
|
|
const b = this.snapPoint(end);
|
|
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
|
|
}
|
|
|
|
clearGesture() {
|
|
this.gesture = null;
|
|
if (this.priceTag) this.priceTag.hidden = true;
|
|
// The pending anchor's rubber band must survive between the two clicks.
|
|
if (!this.pendingAnchor) this.clearLinePreview();
|
|
}
|
|
|
|
setClickHandler(handler) { this.onChartClick = handler; }
|
|
|
|
setLineChangeHandler(handler) { this.onLineChange = handler; }
|
|
|
|
setLineEndHandler(handler) { this.onLineEnd = handler; }
|
|
|
|
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 - this.plotOffsetX();
|
|
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;
|