chart/static/chart.js
Chris Amow 1a1514bc77 Add a price alert input
There was no way to say "tell me when ES reaches 7800". The only user-settable
alert was a drawn trendline, placed by clicking two points on a canvas — so you
could not hit an exact price, and making the line flat was fiddly.

A price alert is a manual line with zero slope. Reusing that rather than
building a parallel concept means it inherits JSON persistence, renaming,
recolouring, deletion, clustering, and the rule that a hand-placed level alerts
whatever its confluence score. The only genuinely new code is the input, an
endpoint that takes a price instead of two anchors, and the decision to render
zero-slope manual lines as price lines — which spans the chart and labels the
axis, instead of drawing a stubby two-point segment.

Zero-slope lines also skip drag handles, hit-testing and the "end line here"
menu: a price line has no endpoints to grab. They are managed from the sidebar.

Unlabelled alerts are named by their price, since "1d resistance" does not say
which alert fired.

Verified live: a level typed 25 points above price clusters as resistance at
that price and stays quiet, as it should until price arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:09:14 -05:00

451 lines
18 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.onChartMove = 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;
}
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.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();
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);
if (this.onChartMove) this.onChartMove(param);
});
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
}
setBars(bars) {
this.bars = bars;
this.candles.setData(bars.map(this.toCandle));
this.chart.timeScale().setVisibleLogicalRange({
from: Math.max(0, bars.length - 160),
to: bars.length + 5,
});
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));
}
}
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 = (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);
}
this.renderAnchorHandles();
}
setClickHandler(handler) { this.onChartClick = handler; }
setMoveHandler(handler) { this.onChartMove = 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`;
}
pointFromClick(param, snap) {
const rawPrice = this.candles.coordinateToPrice(param.point.y);
let point = { t: Number(param.time), p: rawPrice, snappedSide: null };
if (!snap || !this.bars.length) return point;
const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? 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) - param.point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return point;
}
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;
}
lineData(level) {
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
const points = [
{ time: level.anchor_t, value: level.anchor_p },
{ time: level.last_t, value: secondPrice },
];
const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t);
const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t);
const cutoffIndex = level.cutoff_t == null
? -1
: this.bars.findIndex(bar => bar.t >= level.cutoff_t);
const targetIndex = cutoffIndex >= 0 ? cutoffIndex : this.bars.length - 1;
if (anchorIndex >= 0 && secondIndex > anchorIndex && targetIndex > secondIndex) {
const value = level.anchor_p
+ (secondPrice - level.anchor_p) * (targetIndex - anchorIndex) / (secondIndex - anchorIndex);
points.push({ time: this.bars[targetIndex].t, value });
}
return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time);
}
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.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;