chart/static/chart.js

176 lines
6.3 KiB
JavaScript

class ConfluenceChart {
constructor() {
this.chart = null;
this.candles = null;
this.resizeObserver = null;
this.levelSeries = new Map();
this.previewSeries = null;
this.bars = [];
this.levels = [];
this.onChartClick = null;
this.onChartMove = null;
}
create(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 });
});
this.resizeObserver.observe(el);
this.chart.subscribeClick(param => {
if (param.point && param.time && this.onChartClick) this.onChartClick(param);
});
this.chart.subscribeCrosshairMove(param => {
if (param.point && param.time && this.onChartMove) this.onChartMove(param);
});
}
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,
});
}
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);
}
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: ConfluenceChart.tfColors[level.tf],
lineWidth: 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: true,
title: level.label,
};
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 }));
} else {
const first = this.bars[0]?.t || level.anchor_t;
const last = this.bars[this.bars.length - 1]?.t || level.last_t;
const extension = Math.max(60, Math.floor((last - first) * 0.2));
const end = Math.max(last, level.last_t) + extension;
data = [
{ time: level.anchor_t, value: level.anchor_p },
{ time: end, value: level.anchor_p + level.slope * (end - level.anchor_t) },
];
}
entry.series.setData(data);
}
}
setClickHandler(handler) { this.onChartClick = handler; }
setMoveHandler(handler) { this.onChartMove = handler; }
setLinePreview(start, end, tf) {
if (start.t === end.t) return;
if (!this.previewSeries) {
this.previewSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
color: ConfluenceChart.tfColors[tf],
lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.Dashed,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
}
this.previewSeries.applyOptions({ color: ConfluenceChart.tfColors[tf] });
this.previewSeries.setData([start, end]
.sort((a, b) => a.t - b.t)
.map(point => ({ time: point.t, value: point.p })));
}
clearLinePreview() {
if (!this.previewSeries) return;
this.chart.removeSeries(this.previewSeries);
this.previewSeries = null;
}
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) {
const t = Number(param.time);
let best = null;
for (const level of this.levels.filter(value => value.kind === 'manual')) {
const price = level.anchor_p + level.slope * (t - level.anchor_t);
const coordinate = this.candles.priceToCoordinate(price);
const distance = Math.abs(coordinate - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
}
return best?.id || null;
}
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.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;