chart/static/chart.js

400 lines
16 KiB
JavaScript

class ConfluenceChart {
constructor() {
this.chart = null;
this.candles = null;
this.resizeObserver = null;
this.levelSeries = 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();
}
syncLevels(levels) {
this.levels = levels;
const wanted = new Set(levels.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 levels) {
if (level.hidden) continue;
let entry = this.levelSeries.get(level.id);
const isMa = level.kind === 'ma';
const options = {
color: level.color || ConfluenceChart.tfColors[level.tf],
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 (isMa) {
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;
for (const level of this.levels.filter(value => value.kind === 'manual')) {
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');
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', level.color || ConfluenceChart.tfColors[level.tf]);
});
}
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');
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', '4h':'#ec7b42', '1d':'#d96073',
};
window.ConfluenceChart = ConfluenceChart;