|
|
|
|
@ -10,6 +10,7 @@ class ConfluenceChart {
|
|
|
|
|
this.currentPriceLine = null;
|
|
|
|
|
this.currentPricePulse = null;
|
|
|
|
|
this.projectionDiagnostic = null;
|
|
|
|
|
this.projectionDiagnosticFrame = null;
|
|
|
|
|
this.animateCurrentPrice = true;
|
|
|
|
|
this.autoScrollLivePrice = true;
|
|
|
|
|
this.lastCurrentPrice = null;
|
|
|
|
|
@ -66,7 +67,10 @@ class ConfluenceChart {
|
|
|
|
|
this.commentNodes = new Map();
|
|
|
|
|
this.commentLayer = null;
|
|
|
|
|
this.contextLabelLayer = null;
|
|
|
|
|
this.contextLabelFrame = null;
|
|
|
|
|
this.overlayFrame = null;
|
|
|
|
|
this.overlayForce = false;
|
|
|
|
|
this.overlayRangeKey = '';
|
|
|
|
|
this.pointerAttached = { tool: false, anchor: false, line: false };
|
|
|
|
|
this.onCommentToggle = null;
|
|
|
|
|
this.onCommentMove = null;
|
|
|
|
|
this.onCommentSelect = null;
|
|
|
|
|
@ -254,9 +258,7 @@ class ConfluenceChart {
|
|
|
|
|
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
this.observePlotCanvas();
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
this.resizeObserver.observe(el);
|
|
|
|
|
@ -397,16 +399,10 @@ class ConfluenceChart {
|
|
|
|
|
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.lineMoveListener = event => this.moveLine(event);
|
|
|
|
|
this.lineUpListener = event => this.finishLineDrag(event);
|
|
|
|
|
window.addEventListener('pointermove', this.lineMoveListener);
|
|
|
|
|
window.addEventListener('pointerup', this.lineUpListener);
|
|
|
|
|
this.contextListener = event => this.showContextMenu(event);
|
|
|
|
|
el.addEventListener('contextmenu', this.contextListener);
|
|
|
|
|
this.clickListener = event => {
|
|
|
|
|
@ -423,7 +419,6 @@ class ConfluenceChart {
|
|
|
|
|
};
|
|
|
|
|
el.addEventListener('click', this.clickListener);
|
|
|
|
|
this.chart.subscribeCrosshairMove(param => {
|
|
|
|
|
this.scheduleContextLabels();
|
|
|
|
|
if (!param.point || !param.time) {
|
|
|
|
|
this.tooltip.hidden = true;
|
|
|
|
|
this.hideOhlc();
|
|
|
|
|
@ -434,11 +429,7 @@ class ConfluenceChart {
|
|
|
|
|
});
|
|
|
|
|
requestAnimationFrame(() => this.observePlotCanvas());
|
|
|
|
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
this.renderLineBridges();
|
|
|
|
|
this.renderProjectionDiagnostic();
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.scheduleOverlays();
|
|
|
|
|
});
|
|
|
|
|
this.installPinchZoom();
|
|
|
|
|
}
|
|
|
|
|
@ -469,12 +460,7 @@ class ConfluenceChart {
|
|
|
|
|
};
|
|
|
|
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
|
|
|
|
// The bar grid just changed underneath every pinned comment.
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.renderLineBridges();
|
|
|
|
|
this.renderProjectionDiagnostic();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTrendlineGeometry(geometry) {
|
|
|
|
|
@ -591,10 +577,7 @@ class ConfluenceChart {
|
|
|
|
|
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);
|
|
|
|
|
if (advanced) this.extendManualLineSeries(last, bar);
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
if (advanced || this.lineBridgeLayer?.childElementCount) this.renderLineBridges();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.renderProjectionDiagnostic();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extendManualLineSeries(previous, bar) {
|
|
|
|
|
@ -623,7 +606,7 @@ class ConfluenceChart {
|
|
|
|
|
|
|
|
|
|
setComments(comments) {
|
|
|
|
|
this.comments = comments;
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setCurrentPriceAnimation(enabled) {
|
|
|
|
|
@ -804,9 +787,7 @@ class ConfluenceChart {
|
|
|
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
this.updateCurrentPrice(this.lastCurrentPrice);
|
|
|
|
|
};
|
|
|
|
|
this.touchEndListener = event => {
|
|
|
|
|
@ -1174,10 +1155,7 @@ class ConfluenceChart {
|
|
|
|
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
|
|
|
|
this.pendingView = null;
|
|
|
|
|
}
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
this.renderLineBridges();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.renderProjectionDiagnostic();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderContextLabels() {
|
|
|
|
|
@ -1215,14 +1193,61 @@ class ConfluenceChart {
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scheduleContextLabels() {
|
|
|
|
|
if (this.contextLabelFrame != null) return;
|
|
|
|
|
this.contextLabelFrame = requestAnimationFrame(() => {
|
|
|
|
|
this.contextLabelFrame = null;
|
|
|
|
|
overlayKey() {
|
|
|
|
|
const range = this.chart?.timeScale().getVisibleLogicalRange();
|
|
|
|
|
if (!range) return '';
|
|
|
|
|
const price = this.manualPriceRange;
|
|
|
|
|
return `${range.from.toFixed(4)}:${range.to.toFixed(4)}:${price?.minValue ?? ''}:${price?.maxValue ?? ''}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scheduleOverlays(force = false) {
|
|
|
|
|
this.overlayForce = this.overlayForce || force;
|
|
|
|
|
if (this.overlayFrame != null) return;
|
|
|
|
|
this.overlayFrame = requestAnimationFrame(() => {
|
|
|
|
|
this.overlayFrame = null;
|
|
|
|
|
const forceDraw = this.overlayForce;
|
|
|
|
|
this.overlayForce = false;
|
|
|
|
|
const key = this.overlayKey();
|
|
|
|
|
if (!forceDraw && key && key === this.overlayRangeKey) return;
|
|
|
|
|
this.overlayRangeKey = key;
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
this.renderLineBridges();
|
|
|
|
|
this.renderProjectionDiagnostic();
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
attachPointers(kind) {
|
|
|
|
|
if (this.pointerAttached[kind]) return;
|
|
|
|
|
this.pointerAttached[kind] = true;
|
|
|
|
|
if (kind === 'tool') {
|
|
|
|
|
window.addEventListener('pointermove', this.toolMoveListener);
|
|
|
|
|
window.addEventListener('pointerup', this.toolUpListener);
|
|
|
|
|
} else if (kind === 'anchor') {
|
|
|
|
|
window.addEventListener('pointermove', this.anchorMoveListener);
|
|
|
|
|
window.addEventListener('pointerup', this.anchorUpListener);
|
|
|
|
|
} else if (kind === 'line') {
|
|
|
|
|
window.addEventListener('pointermove', this.lineMoveListener);
|
|
|
|
|
window.addEventListener('pointerup', this.lineUpListener);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
detachPointers(kind) {
|
|
|
|
|
if (!this.pointerAttached[kind]) return;
|
|
|
|
|
this.pointerAttached[kind] = false;
|
|
|
|
|
if (kind === 'tool') {
|
|
|
|
|
window.removeEventListener('pointermove', this.toolMoveListener);
|
|
|
|
|
window.removeEventListener('pointerup', this.toolUpListener);
|
|
|
|
|
} else if (kind === 'anchor') {
|
|
|
|
|
window.removeEventListener('pointermove', this.anchorMoveListener);
|
|
|
|
|
window.removeEventListener('pointerup', this.anchorUpListener);
|
|
|
|
|
} else if (kind === 'line') {
|
|
|
|
|
window.removeEventListener('pointermove', this.lineMoveListener);
|
|
|
|
|
window.removeEventListener('pointerup', this.lineUpListener);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- tool arming and placement gestures ---------------------------------
|
|
|
|
|
|
|
|
|
|
setToolCompleteHandler(handler) { this.onToolComplete = handler; }
|
|
|
|
|
@ -1255,6 +1280,8 @@ class ConfluenceChart {
|
|
|
|
|
this.pendingAnchor = null;
|
|
|
|
|
this.clearGesture();
|
|
|
|
|
this.clearLinePreview();
|
|
|
|
|
if (tool) this.attachPointers('tool');
|
|
|
|
|
else this.detachPointers('tool');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@ -1292,11 +1319,7 @@ class ConfluenceChart {
|
|
|
|
|
this.observedPlotCanvas = canvas;
|
|
|
|
|
this.plotResizeObserver = new ResizeObserver(() => {
|
|
|
|
|
this.syncOverlayLayer();
|
|
|
|
|
this.renderAnchorHandles();
|
|
|
|
|
this.renderLineBridges();
|
|
|
|
|
this.renderProjectionDiagnostic();
|
|
|
|
|
this.renderComments();
|
|
|
|
|
this.renderContextLabels();
|
|
|
|
|
this.scheduleOverlays(true);
|
|
|
|
|
if (this.armedTool) this.renderGesture();
|
|
|
|
|
});
|
|
|
|
|
this.plotResizeObserver.observe(canvas);
|
|
|
|
|
@ -1377,7 +1400,10 @@ class ConfluenceChart {
|
|
|
|
|
const point = this.eventPoint(event);
|
|
|
|
|
if (!point) return;
|
|
|
|
|
if (!this.armedTool) {
|
|
|
|
|
if (this.withinPlot(point)) this.mousePan = { start: point };
|
|
|
|
|
if (this.withinPlot(point)) {
|
|
|
|
|
this.mousePan = { start: point };
|
|
|
|
|
this.attachPointers('tool');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
@ -1546,6 +1572,7 @@ class ConfluenceChart {
|
|
|
|
|
|
|
|
|
|
finishToolGesture(event) {
|
|
|
|
|
this.mousePan = null;
|
|
|
|
|
if (!this.armedTool) this.detachPointers('tool');
|
|
|
|
|
if (!this.armedTool || !this.gesture) return;
|
|
|
|
|
const tool = this.armedTool;
|
|
|
|
|
const { start, end } = this.gesture;
|
|
|
|
|
@ -2120,10 +2147,70 @@ class ConfluenceChart {
|
|
|
|
|
.find(point => Number(point.time) === future.time)?.value;
|
|
|
|
|
return { level, previous, current, next, series, historical: slope(previous, current), future: slope(current, next) };
|
|
|
|
|
}).filter(row => row.current.y != null && row.current.y >= 0 && row.current.y <= plotHeight);
|
|
|
|
|
this.projectionDiagnostic.textContent = lines.map(row =>
|
|
|
|
|
`#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} ${row.series == null ? 'MISSING' : 'OK'}`,
|
|
|
|
|
).join('\n');
|
|
|
|
|
const text = (row, painted = null) =>
|
|
|
|
|
`#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} paint ${painted ? `${painted.historical?.toFixed(3) ?? '?'}/${painted.future?.toFixed(3) ?? '?'}` : '?/?'} ${row.series == null ? 'MISSING' : 'OK'}`;
|
|
|
|
|
this.projectionDiagnostic.textContent = lines.map(row => text(row)).join('\n');
|
|
|
|
|
this.projectionDiagnostic.hidden = !lines.length;
|
|
|
|
|
if (this.projectionDiagnosticFrame != null) cancelAnimationFrame(this.projectionDiagnosticFrame);
|
|
|
|
|
this.projectionDiagnosticFrame = requestAnimationFrame(() => {
|
|
|
|
|
this.projectionDiagnosticFrame = null;
|
|
|
|
|
this.projectionDiagnostic.textContent = lines.map(row => text(row, {
|
|
|
|
|
historical: this.paintedSlopeBetween(row.previous, row.current, ConfluenceChart.levelColor(row.level)),
|
|
|
|
|
future: this.paintedSlopeBetween(row.current, row.next, ConfluenceChart.levelColor(row.level)),
|
|
|
|
|
})).join('\n');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
paintedSlopeBetween(first, second, color) {
|
|
|
|
|
if ([first.x, first.y, second.x, second.y].some(value => value == null)) return null;
|
|
|
|
|
const at = fraction => ({
|
|
|
|
|
x: first.x + (second.x - first.x) * fraction,
|
|
|
|
|
y: first.y + (second.y - first.y) * fraction,
|
|
|
|
|
});
|
|
|
|
|
const a = at(0.25);
|
|
|
|
|
const b = at(0.75);
|
|
|
|
|
const y1 = this.paintedYAt(a.x, a.y, color);
|
|
|
|
|
const y2 = this.paintedYAt(b.x, b.y, color);
|
|
|
|
|
return y1 == null || y2 == null ? null : (y2 - y1) / (b.x - a.x);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
paintedYAt(x, expectedY, color) {
|
|
|
|
|
const match = /^#([0-9a-f]{6})$/i.exec(color || '');
|
|
|
|
|
if (!match) return null;
|
|
|
|
|
const value = Number.parseInt(match[1], 16);
|
|
|
|
|
const target = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
|
|
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
|
|
|
if (!plot) return null;
|
|
|
|
|
const pageX = plot.left + x;
|
|
|
|
|
const candidates = [];
|
|
|
|
|
for (const canvas of this.chartEl.querySelectorAll('canvas')) {
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
if (!rect.width || !rect.height || pageX < rect.left || pageX > rect.right) continue;
|
|
|
|
|
const scaleX = canvas.width / rect.width;
|
|
|
|
|
const scaleY = canvas.height / rect.height;
|
|
|
|
|
const pixelX = Math.round((pageX - rect.left) * scaleX);
|
|
|
|
|
const top = Math.max(0, Math.floor((plot.top + expectedY - 8 - rect.top) * scaleY));
|
|
|
|
|
const bottom = Math.min(canvas.height - 1, Math.ceil((plot.top + expectedY + 8 - rect.top) * scaleY));
|
|
|
|
|
if (bottom < top) continue;
|
|
|
|
|
try {
|
|
|
|
|
const image = canvas.getContext('2d').getImageData(
|
|
|
|
|
Math.max(0, pixelX - 1), top, Math.min(3, canvas.width - Math.max(0, pixelX - 1)), bottom - top + 1,
|
|
|
|
|
);
|
|
|
|
|
for (let offset = 0; offset < image.data.length; offset += 4) {
|
|
|
|
|
if (image.data[offset + 3] < 80) continue;
|
|
|
|
|
const distance = Math.hypot(
|
|
|
|
|
image.data[offset] - target[0], image.data[offset + 1] - target[1],
|
|
|
|
|
image.data[offset + 2] - target[2],
|
|
|
|
|
);
|
|
|
|
|
if (distance > 55) continue;
|
|
|
|
|
const row = Math.floor((offset / 4) / image.width);
|
|
|
|
|
candidates.push(rect.top - plot.top + (top + row) / scaleY);
|
|
|
|
|
}
|
|
|
|
|
} catch { /* An unreadable internal canvas contributes no measurement. */ }
|
|
|
|
|
}
|
|
|
|
|
if (!candidates.length) return null;
|
|
|
|
|
candidates.sort((a, b) => Math.abs(a - expectedY) - Math.abs(b - expectedY));
|
|
|
|
|
return candidates[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lineData(level) {
|
|
|
|
|
@ -2242,6 +2329,7 @@ class ConfluenceChart {
|
|
|
|
|
original: { ...level },
|
|
|
|
|
moved: false,
|
|
|
|
|
};
|
|
|
|
|
this.attachPointers('line');
|
|
|
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -2291,6 +2379,7 @@ class ConfluenceChart {
|
|
|
|
|
const level = this.levels.find(value => value.id === this.draggingLine.id);
|
|
|
|
|
const moved = this.draggingLine.moved;
|
|
|
|
|
this.draggingLine = null;
|
|
|
|
|
this.detachPointers('line');
|
|
|
|
|
if (moved && level && this.onLineChange) this.onLineChange({ ...level });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -2303,6 +2392,7 @@ class ConfluenceChart {
|
|
|
|
|
anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor,
|
|
|
|
|
};
|
|
|
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
|
|
|
this.attachPointers('anchor');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
moveAnchor(event) {
|
|
|
|
|
@ -2350,6 +2440,7 @@ class ConfluenceChart {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const level = this.levels.find(value => value.id === this.draggingAnchor.id);
|
|
|
|
|
this.draggingAnchor = null;
|
|
|
|
|
this.detachPointers('anchor');
|
|
|
|
|
this.hideSnapDot();
|
|
|
|
|
if (level && this.onLineChange) this.onLineChange({ ...level });
|
|
|
|
|
}
|
|
|
|
|
@ -2425,7 +2516,10 @@ class ConfluenceChart {
|
|
|
|
|
destroy() {
|
|
|
|
|
if (this.resizeObserver) this.resizeObserver.disconnect();
|
|
|
|
|
if (this.plotResizeObserver) this.plotResizeObserver.disconnect();
|
|
|
|
|
if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame);
|
|
|
|
|
if (this.overlayFrame != null) cancelAnimationFrame(this.overlayFrame);
|
|
|
|
|
this.detachPointers('tool');
|
|
|
|
|
this.detachPointers('anchor');
|
|
|
|
|
this.detachPointers('line');
|
|
|
|
|
clearTimeout(this.lineFocusTimer);
|
|
|
|
|
for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer);
|
|
|
|
|
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
|
|
|
|
|
|