The hourly chart drew 160 candles as unreadable slivers. Level points are sampled on their own timeframe — VWAP every minute, the daily averages once a session — and every distinct timestamp claims a slot on the chart's shared scale. A minute-resolution VWAP on an hourly chart therefore spread 160 candles across 908 slots, five to six times wider than the candles they belonged to: before 1h inView 160 slots 908 ratio 5.46 after 1h inView 160 slots 161 ratio 1.01 Snapping ma and vwap points onto the candle grid fixes the density without changing the line: points collapse onto the candle at or after them, and the newest wins. Trendlines keep the raw path — lineData interpolates between two anchors, and snapping those would move the geometry the user drew. Two details worth keeping. Points past the final candle are clamped onto it rather than 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 straight back on the scale. And the viewport is re-applied once after the level series have loaded, because setBars runs before them and the chart holds the width it derived from the previous timeframe's density — consumed rather than reapplied, so the minutely VWAP resync cannot yank the view back from wherever it has been panned. The method is snapPointsToBars, not snapToBars: this.snapToBars already exists as the "Snap to highs/lows" boolean, and the assignment silently replaced the prototype method with true. Verified in a browser across all six timeframes — 1m, 5m, 15m, 30m, 1h and 1d each show 160 candles at ratio 0.99–1.01 with no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
695 lines
28 KiB
JavaScript
695 lines
28 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;
|
|
}
|
|
|
|
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 },
|
|
rightPriceScale: { borderVisible: false },
|
|
});
|
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
|
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
|
borderUpColor: '#1d6849', borderDownColor: '#963737',
|
|
wickUpColor: '#1d6849', wickDownColor: '#963737',
|
|
});
|
|
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.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());
|
|
}
|
|
|
|
setBars(bars) {
|
|
this.bars = bars;
|
|
this.candles.setData(bars.map(this.toCandle));
|
|
// 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) {
|
|
this.candles.update(this.toCandle(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();
|
|
}
|
|
|
|
// 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, line] of this.priceLines) {
|
|
if (!wanted.has(id)) {
|
|
this.candles.removePriceLine(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.applyOptions(options);
|
|
else this.priceLines.set(level.id, this.candles.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) {
|
|
entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, options) };
|
|
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 === '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.
|
|
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
|
const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? 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. */
|
|
snapPoint(point) {
|
|
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
|
|
const base = { t: fallbackT, p: point.p, snappedSide: null };
|
|
if (!this.snapToBars || !this.bars.length || fallbackT == 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];
|
|
if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
|
return base;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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;
|