3412 lines
138 KiB
JavaScript
3412 lines
138 KiB
JavaScript
// Vertical session lines via Lightweight Charts' series primitive — canvas,
|
|
// not a DOM overlay. createPriceLine is horizontal only; attachPrimitive is
|
|
// the matching hook for a time.
|
|
class RthLinesPrimitive {
|
|
constructor() {
|
|
this._times = [];
|
|
this._styles = [];
|
|
this._xs = [];
|
|
this._chart = null;
|
|
this._requestUpdate = null;
|
|
this._view = {
|
|
update: () => this._sync(),
|
|
renderer: () => this._renderer,
|
|
};
|
|
this._renderer = { draw: target => this._draw(target) };
|
|
}
|
|
|
|
attached(param) {
|
|
this._chart = param.chart;
|
|
this._requestUpdate = param.requestUpdate;
|
|
}
|
|
|
|
detached() {
|
|
this._chart = null;
|
|
this._requestUpdate = null;
|
|
}
|
|
|
|
setMarks(marks) {
|
|
this._times = marks.map(mark => mark.t);
|
|
this._styles = marks.map(mark => mark.kind);
|
|
if (this._requestUpdate) this._requestUpdate();
|
|
}
|
|
|
|
updateAllViews() {
|
|
this._view.update();
|
|
}
|
|
|
|
paneViews() {
|
|
return [this._view];
|
|
}
|
|
|
|
_sync() {
|
|
if (!this._chart) {
|
|
this._xs = [];
|
|
return;
|
|
}
|
|
const scale = this._chart.timeScale();
|
|
this._xs = this._times.map(time => scale.timeToCoordinate(time));
|
|
}
|
|
|
|
_draw(target) {
|
|
if (!this._xs.length) return;
|
|
target.useBitmapCoordinateSpace(scope => {
|
|
const ctx = scope.context;
|
|
const height = scope.bitmapSize.height;
|
|
const width = Math.max(1, Math.round(scope.horizontalPixelRatio));
|
|
ctx.save();
|
|
for (let i = 0; i < this._xs.length; i += 1) {
|
|
const x = this._xs[i];
|
|
if (x == null) continue;
|
|
const px = Math.round(x * scope.horizontalPixelRatio);
|
|
ctx.strokeStyle = 'rgba(44,41,36,0.20)';
|
|
ctx.lineWidth = width;
|
|
ctx.setLineDash(this._styles[i] === 'close' ? [3 * width, 4 * width] : []);
|
|
ctx.beginPath();
|
|
ctx.moveTo(px + 0.5, 0);
|
|
ctx.lineTo(px + 0.5, height);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
});
|
|
}
|
|
}
|
|
|
|
class FibLevelsPrimitive {
|
|
constructor(locateTime) {
|
|
this._locate = locateTime;
|
|
this._fibs = [];
|
|
this._series = null;
|
|
this._requestUpdate = null;
|
|
this._items = [];
|
|
this._view = {
|
|
update: () => this._sync(),
|
|
renderer: () => this._renderer,
|
|
};
|
|
this._renderer = { draw: target => this._draw(target) };
|
|
}
|
|
|
|
attached(param) {
|
|
this._series = param.series;
|
|
this._requestUpdate = param.requestUpdate;
|
|
}
|
|
|
|
detached() {
|
|
this._series = null;
|
|
this._requestUpdate = null;
|
|
}
|
|
|
|
setFibs(fibs) {
|
|
this._fibs = fibs || [];
|
|
if (this._requestUpdate) this._requestUpdate();
|
|
}
|
|
|
|
updateAllViews() {
|
|
this._view.update();
|
|
}
|
|
|
|
paneViews() {
|
|
return [this._view];
|
|
}
|
|
|
|
_sync() {
|
|
const items = [];
|
|
if (!this._series) {
|
|
this._items = [];
|
|
return;
|
|
}
|
|
for (const fib of this._fibs) {
|
|
if (fib.hidden) continue;
|
|
const x0 = this._locate(fib.anchor_t);
|
|
const y0 = this._series.priceToCoordinate(Number(fib.anchor_p));
|
|
const endP = Number(fib.anchor_p) + Number(fib.slope || 0)
|
|
* (Number(fib.last_t) - Number(fib.anchor_t));
|
|
const y1 = this._series.priceToCoordinate(endP);
|
|
const width = Math.max(1, Number(fib.line_width) || 2);
|
|
const color = fib.color || '#c8992f';
|
|
if (x0 != null && y0 != null && y1 != null) {
|
|
items.push({
|
|
kind: 'rail', x: x0, y0, y1, color, width,
|
|
});
|
|
}
|
|
for (const level of ConfluenceChart.fibPrices(fib)) {
|
|
const y = this._series.priceToCoordinate(level.price);
|
|
if (y == null) continue;
|
|
const major = level.r === 0 || level.r === 0.5 || level.r === 0.618 || level.r === 1;
|
|
items.push({
|
|
kind: 'level',
|
|
x0,
|
|
y,
|
|
color,
|
|
width,
|
|
dashed: !major,
|
|
label: `fib ${level.label} ${level.price.toFixed(2)}`,
|
|
});
|
|
}
|
|
}
|
|
this._items = items;
|
|
}
|
|
|
|
_draw(target) {
|
|
if (!this._items.length) return;
|
|
target.useMediaCoordinateSpace(scope => {
|
|
const ctx = scope.context;
|
|
const right = scope.mediaSize.width;
|
|
ctx.save();
|
|
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
|
for (const item of this._items) {
|
|
if (item.kind === 'rail') {
|
|
ctx.strokeStyle = item.color;
|
|
ctx.fillStyle = item.color;
|
|
ctx.lineWidth = item.width;
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.beginPath();
|
|
ctx.moveTo(item.x + 0.5, item.y0);
|
|
ctx.lineTo(item.x + 0.5, item.y1);
|
|
ctx.stroke();
|
|
ctx.save();
|
|
ctx.font = '20px "IBM Plex Mono", ui-monospace, monospace';
|
|
ctx.translate(item.x, (item.y0 + item.y1) / 2);
|
|
ctx.rotate(-Math.PI / 2);
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = item.x > 22 ? 'bottom' : 'top';
|
|
ctx.fillText('fibonacci', 0, item.x > 22 ? -2 : 2);
|
|
ctx.restore();
|
|
continue;
|
|
}
|
|
const start = item.x0 == null ? 0 : Math.max(0, item.x0);
|
|
if (start > right) continue;
|
|
ctx.strokeStyle = item.color;
|
|
ctx.fillStyle = item.color;
|
|
ctx.lineWidth = item.width;
|
|
ctx.setLineDash(item.dashed ? [5, 4] : []);
|
|
ctx.globalAlpha = 0.9;
|
|
ctx.beginPath();
|
|
ctx.moveTo(start, item.y + 0.5);
|
|
ctx.lineTo(right, item.y + 0.5);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillText(item.label, right - ctx.measureText(item.label).width - 6, item.y - 4);
|
|
}
|
|
ctx.restore();
|
|
});
|
|
}
|
|
}
|
|
|
|
class ConfluenceChart {
|
|
constructor() {
|
|
this.chart = null;
|
|
this.candles = null;
|
|
this.resizeObserver = null;
|
|
this.plotResizeObserver = null;
|
|
this.observedPlotCanvas = null;
|
|
this.levelSeries = new Map();
|
|
this.priceLines = new Map();
|
|
this.currentPriceLine = null;
|
|
this.currentPricePulse = null;
|
|
this.projectionDiagnostic = null;
|
|
this.projectionDiagnosticFrame = null;
|
|
this.animateCurrentPrice = true;
|
|
this.autoScrollLivePrice = true;
|
|
this.lastCurrentPrice = null;
|
|
this.pendingBar = null;
|
|
this.pendingBarFrame = null;
|
|
this.previewLine = null;
|
|
this.lineBridgeLayer = null;
|
|
this.bars = [];
|
|
this.levels = [];
|
|
this.trendlineGeometryMode = 'legacy';
|
|
this.trendlineSourceSeries = new Map();
|
|
this.onChartClick = null;
|
|
this.tooltip = null;
|
|
this.chartEl = null;
|
|
this.clickListener = null;
|
|
this.selectedLineId = null;
|
|
this.selectedDrawingId = null;
|
|
this.anchorHandles = [];
|
|
this.lineHitTarget = null;
|
|
this.lineFocus = null;
|
|
this.lineFocusTimer = null;
|
|
this.fibFocus = null;
|
|
this.fibFocusTimer = null;
|
|
this.draggingLine = null;
|
|
this.lineMoveListener = null;
|
|
this.lineUpListener = null;
|
|
this.draggingAnchor = null;
|
|
this.onLineChange = null;
|
|
this.anchorMoveListener = null;
|
|
this.anchorUpListener = null;
|
|
this.contextMenu = null;
|
|
this.contextCutoff = null;
|
|
this.contextLineId = null;
|
|
this.contextEndButton = null;
|
|
this.contextExtendButton = null;
|
|
this.contextListener = null;
|
|
this.onLineEnd = null;
|
|
this.onLineDuplicate = null;
|
|
this.onLineDelete = 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;
|
|
this.overlayLayer = null;
|
|
this.rthPrimitive = null;
|
|
this.rthEnabled = true;
|
|
this.fibs = [];
|
|
this.fibPrimitive = null;
|
|
this.fibPreview = null;
|
|
this.futureSpace = null;
|
|
this.displayFutureTimes = [];
|
|
this.snapDot = null;
|
|
this.snapLabel = null;
|
|
this.snapLeader = null;
|
|
this.snapLeaderLine = null;
|
|
this.comments = [];
|
|
this.commentNodes = new Map();
|
|
this.commentLayer = null;
|
|
this.contextLabelLayer = 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;
|
|
this.describeDrawingColor = null;
|
|
this.lastSnapDiagnostic = null;
|
|
this.manualPriceRange = null;
|
|
this.pricePinch = null;
|
|
this.touchStartListener = null;
|
|
this.touchMoveListener = null;
|
|
this.touchEndListener = null;
|
|
this.priceResetListener = null;
|
|
this.touchTap = null;
|
|
this.suppressClickUntil = 0;
|
|
this.candleAutoscaleProvider = original => {
|
|
const info = original();
|
|
if (!info || !this.manualPriceRange) return info;
|
|
return { ...info, priceRange: { ...this.manualPriceRange } };
|
|
};
|
|
}
|
|
|
|
static TICK = 0.25;
|
|
|
|
// Pixels of travel between press and release that make a gesture a drag
|
|
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
|
static DRAG_THRESHOLD = 12;
|
|
|
|
// How far past the last bar the axis is extended. Enough to date a
|
|
// convergence a few sessions out without dominating a zoomed-out view.
|
|
static FUTURE_SLOTS = 180;
|
|
|
|
// Preserve short intraday gaps as empty slots. Longer closures remain
|
|
// compressed, including the 65-minute CME settlement break and weekends.
|
|
static MAX_INTRADAY_GAP_SECONDS = 30 * 60;
|
|
|
|
/**
|
|
* The timeframe's spacing, measured rather than assumed.
|
|
*
|
|
* Taking the gap between the final two bars looks equivalent and is not: the
|
|
* newest bar is often partial or irregularly stamped — a live one arrived at
|
|
* :02 past the minute — which made that gap two seconds on a one-minute
|
|
* chart. Everything derived from it was then wrong by a factor of thirty. A
|
|
* median over recent bars ignores both the ragged tail and session gaps.
|
|
*/
|
|
static barInterval(bars) {
|
|
const deltas = [];
|
|
for (let i = Math.max(1, bars.length - 30); i < bars.length; i += 1) {
|
|
const delta = bars[i].t - bars[i - 1].t;
|
|
if (delta > 0) deltas.push(delta);
|
|
}
|
|
if (!deltas.length) return 0;
|
|
deltas.sort((a, b) => a - b);
|
|
return deltas[Math.floor(deltas.length / 2)];
|
|
}
|
|
|
|
/**
|
|
* Whether to report snap geometry to the server.
|
|
*
|
|
* `?diag=1` turns it on and remembers it; `?diag=0` turns it off. Kept out of
|
|
* the way rather than always on: it exists because the browser runs on a
|
|
* different machine from whoever is debugging it.
|
|
*/
|
|
static diagnosticMode() {
|
|
const asked = new URLSearchParams(location.search).get('diag');
|
|
if (asked === '1') localStorage.setItem('chart-diag', '1');
|
|
if (asked === '0') localStorage.removeItem('chart-diag');
|
|
return localStorage.getItem('chart-diag') === '1';
|
|
}
|
|
|
|
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(),
|
|
attributionLogo: false,
|
|
},
|
|
grid: {
|
|
vertLines: { color: 'rgba(128,128,128,.10)' },
|
|
horzLines: { color: 'rgba(128,128,128,.10)' },
|
|
},
|
|
timeScale: {
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
// The library is timezone-agnostic: it reads epoch seconds as UTC and
|
|
// labels them as UTC. Bars stay in UTC — that convention holds
|
|
// everywhere in this codebase, and shifting the values themselves would
|
|
// put every trendline anchor, hit test and alert an offset out. Only
|
|
// the label is localised, via the browser's own zone.
|
|
tickMarkFormatter: (time, tickMarkType) => {
|
|
const at = new Date(time * 1000);
|
|
const daily = this.bars[0]?.tf === '1d';
|
|
if (tickMarkType === LightweightCharts.TickMarkType.Year) {
|
|
return String(daily ? at.getUTCFullYear() : at.getFullYear());
|
|
}
|
|
if (tickMarkType === LightweightCharts.TickMarkType.Month) {
|
|
return at.toLocaleString(undefined, { month: 'short', ...(daily ? { timeZone: 'UTC' } : {}) });
|
|
}
|
|
if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) {
|
|
return String(daily ? at.getUTCDate() : at.getDate());
|
|
}
|
|
return at.toLocaleTimeString(undefined, {
|
|
hour12: false, hour: '2-digit', minute: '2-digit',
|
|
...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds
|
|
? { second: '2-digit' } : {}),
|
|
});
|
|
},
|
|
},
|
|
localization: {
|
|
// The crosshair readout, which would otherwise disagree with the axis.
|
|
timeFormatter: time => {
|
|
const daily = this.bars[0]?.tf === '1d';
|
|
return new Date(time * 1000).toLocaleString(undefined, daily
|
|
? { timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }
|
|
: { weekday: 'short', hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
},
|
|
},
|
|
rightPriceScale: { borderVisible: false },
|
|
handleScale: {
|
|
mouseWheel: true,
|
|
pinch: false,
|
|
axisPressedMouseMove: { time: true, price: true },
|
|
axisDoubleClickReset: { time: true, price: true },
|
|
},
|
|
handleScroll: {
|
|
mouseWheel: true,
|
|
pressedMouseMove: true,
|
|
horzTouchDrag: false,
|
|
vertTouchDrag: false,
|
|
},
|
|
// Every price-bearing series shares this scale. A second autoscaled price
|
|
// axis can place the same numeric price at a different y, which makes
|
|
// daily context look comparable to intraday price when it is not.
|
|
leftPriceScale: { visible: false },
|
|
// Never magnet. The default snaps the crosshair to the bar's *close*, so
|
|
// hovering beside a low reads a price several ticks away, and everything
|
|
// that snaps in this app snaps to extremes. The crosshair tracks the
|
|
// cursor; the snap dot says where an anchor would actually land.
|
|
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
|
});
|
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
|
upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true,
|
|
borderUpColor: '#087f76', borderDownColor: '#b9342d',
|
|
wickUpColor: '#087f76', wickDownColor: '#b9342d',
|
|
autoscaleInfoProvider: this.candleAutoscaleProvider,
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
});
|
|
// Volume as an overlay on its own hidden scale, confined to the bottom
|
|
// fifth. An overlay rather than a pane so it cannot alter the price scale:
|
|
// volumes are five figures and prices four, and sharing a scale would
|
|
// flatten the candles into a line.
|
|
this.volume = this.chart.addSeries(LightweightCharts.HistogramSeries, {
|
|
priceScaleId: 'volume',
|
|
priceFormat: { type: 'volume' },
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
});
|
|
this.chart.priceScale('volume').applyOptions({
|
|
scaleMargins: { top: 0.88, bottom: 0 },
|
|
visible: false,
|
|
});
|
|
// The default leaves a tenth of the pane empty beneath the lowest bar,
|
|
// which is where the volume draws and where a cursor tracing swing lows
|
|
// naturally sits — far from any actual price. Give most of it back.
|
|
this.chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
|
|
// Whitespace to the right of the last bar. Lightweight Charts only labels
|
|
// times that exist on the scale, so the axis under the projection area was
|
|
// blank — you could watch two trendlines converge and not know when. These
|
|
// points carry no value and draw nothing; they exist so the axis has
|
|
// something to label and so timeToCoordinate answers out there.
|
|
this.rthPrimitive = new RthLinesPrimitive();
|
|
this.candles.attachPrimitive(this.rthPrimitive);
|
|
this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time));
|
|
this.candles.attachPrimitive(this.fibPrimitive);
|
|
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
|
|
priceScaleId: 'right',
|
|
color: 'rgba(0,0,0,0)',
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
crosshairMarkerVisible: false,
|
|
autoscaleInfoProvider: () => null,
|
|
});
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
|
requestAnimationFrame(() => {
|
|
this.observePlotCanvas();
|
|
this.scheduleOverlays(true);
|
|
});
|
|
});
|
|
this.resizeObserver.observe(el);
|
|
// Every overlay lives in here, and this is positioned over the *plot* — not
|
|
// the element, which also contains the price scales. Lightweight Charts
|
|
// reports coordinates from the plot's origin, so anchoring the container
|
|
// there lets each overlay use those coordinates untranslated. Keep the
|
|
// measurement even with no left scale: future layout changes must not
|
|
// silently displace snap indicators, comments or trendline handles.
|
|
this.overlayLayer = document.createElement('div');
|
|
this.overlayLayer.className = 'chart-overlays';
|
|
el.appendChild(this.overlayLayer);
|
|
this.contextLabelLayer = document.createElement('div');
|
|
this.contextLabelLayer.className = 'chart-context-labels';
|
|
this.overlayLayer.appendChild(this.contextLabelLayer);
|
|
this.tooltip = document.createElement('div');
|
|
this.tooltip.className = 'chart-tooltip';
|
|
this.overlayLayer.appendChild(this.tooltip);
|
|
this.ohlcReadout = document.createElement('div');
|
|
this.ohlcReadout.className = 'chart-ohlc';
|
|
this.ohlcReadout.hidden = true;
|
|
this.overlayLayer.appendChild(this.ohlcReadout);
|
|
this.currentPricePulse = document.createElement('div');
|
|
this.currentPricePulse.className = 'current-price-pulse';
|
|
this.currentPricePulse.textContent = '←';
|
|
this.currentPricePulse.hidden = true;
|
|
el.appendChild(this.currentPricePulse);
|
|
this.projectionDiagnostic = document.createElement('pre');
|
|
this.projectionDiagnostic.className = 'projection-diagnostic';
|
|
this.projectionDiagnostic.hidden = true;
|
|
this.overlayLayer.appendChild(this.projectionDiagnostic);
|
|
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);
|
|
this.fibPreview = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
this.fibPreview.setAttribute('hidden', '');
|
|
preview.appendChild(this.fibPreview);
|
|
this.overlayLayer.appendChild(preview);
|
|
this.lineBridgeLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
this.lineBridgeLayer.classList.add('chart-line-bridges');
|
|
this.lineBridgeLayer.setAttribute('aria-hidden', 'true');
|
|
this.overlayLayer.appendChild(this.lineBridgeLayer);
|
|
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
handles.classList.add('chart-handles');
|
|
handles.setAttribute('aria-hidden', 'true');
|
|
this.lineFocus = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
this.lineFocus.classList.add('chart-line-focus');
|
|
this.lineFocus.setAttribute('hidden', '');
|
|
handles.appendChild(this.lineFocus);
|
|
this.fibFocus = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
this.fibFocus.classList.add('chart-fib-focus');
|
|
this.fibFocus.setAttribute('hidden', '');
|
|
handles.appendChild(this.fibFocus);
|
|
this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
this.lineHitTarget.classList.add('chart-line-hit');
|
|
this.lineHitTarget.setAttribute('hidden', '');
|
|
this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event));
|
|
this.lineHitTarget.addEventListener('click', event => event.stopPropagation());
|
|
handles.appendChild(this.lineHitTarget);
|
|
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);
|
|
}
|
|
this.overlayLayer.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.contextEndButton = endHere;
|
|
const extend = document.createElement('button');
|
|
extend.type = 'button';
|
|
extend.textContent = 'Extend trendline';
|
|
extend.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
this.extendSelectedLine();
|
|
});
|
|
this.contextExtendButton = extend;
|
|
const duplicate = document.createElement('button');
|
|
duplicate.type = 'button';
|
|
duplicate.dataset.action = 'duplicate';
|
|
duplicate.textContent = 'Duplicate';
|
|
duplicate.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
const level = this.levels.find(value => value.id === this.contextLineId);
|
|
this.hideContextMenu();
|
|
if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level });
|
|
});
|
|
const deleteLine = document.createElement('button');
|
|
deleteLine.type = 'button';
|
|
deleteLine.dataset.action = 'delete';
|
|
deleteLine.textContent = 'Delete';
|
|
deleteLine.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
const id = this.contextLineId;
|
|
this.hideContextMenu();
|
|
if (id && this.onLineDelete) this.onLineDelete(id);
|
|
});
|
|
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
|
this.contextMenu.append(endHere, extend, duplicate, deleteLine);
|
|
this.overlayLayer.appendChild(this.contextMenu);
|
|
this.priceTag = document.createElement('div');
|
|
this.priceTag.className = 'chart-price-tag';
|
|
this.priceTag.hidden = true;
|
|
this.overlayLayer.appendChild(this.priceTag);
|
|
this.snapDot = document.createElement('div');
|
|
this.snapDot.className = 'chart-snap-dot';
|
|
this.snapDot.hidden = true;
|
|
this.overlayLayer.appendChild(this.snapDot);
|
|
// States the price the anchor will use. Useful in itself, and it means a
|
|
// single screenshot answers "is the dot in the wrong place, or on the
|
|
// wrong bar?" without anyone pasting console output.
|
|
this.snapLabel = document.createElement('div');
|
|
this.snapLabel.className = 'chart-snap-label';
|
|
this.snapLabel.hidden = true;
|
|
this.overlayLayer.appendChild(this.snapLabel);
|
|
// A leader from the cursor to the target. The snap is often far from the
|
|
// pointer — hovering below the candles legitimately snaps up to a bar's low
|
|
// — and without a line joining them the dot reads as unrelated to where you
|
|
// are pointing.
|
|
this.snapLeader = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
this.snapLeader.classList.add('chart-snap-leader');
|
|
this.snapLeader.setAttribute('aria-hidden', 'true');
|
|
this.snapLeaderLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
this.snapLeader.appendChild(this.snapLeaderLine);
|
|
this.overlayLayer.appendChild(this.snapLeader);
|
|
this.commentLayer = document.createElement('div');
|
|
this.commentLayer.className = 'chart-comments';
|
|
this.overlayLayer.appendChild(this.commentLayer);
|
|
this.toolDownListener = event => this.startToolGesture(event);
|
|
this.toolMoveListener = event => this.moveToolGesture(event);
|
|
this.toolUpListener = event => this.finishToolGesture(event);
|
|
el.addEventListener('pointerdown', this.toolDownListener);
|
|
this.anchorMoveListener = event => this.moveAnchor(event);
|
|
this.anchorUpListener = event => this.finishAnchorDrag(event);
|
|
this.lineMoveListener = event => this.moveLine(event);
|
|
this.lineUpListener = event => this.finishLineDrag(event);
|
|
this.contextListener = event => this.showContextMenu(event);
|
|
el.addEventListener('contextmenu', this.contextListener);
|
|
this.clickListener = event => {
|
|
this.hideContextMenu();
|
|
if (Date.now() < this.suppressClickUntil) return;
|
|
// 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 point = this.eventPoint(event);
|
|
if (point && this.withinPlot(point) && point.t != null) {
|
|
this.onChartClick({ point, time: point.t });
|
|
}
|
|
};
|
|
el.addEventListener('click', this.clickListener);
|
|
this.chart.subscribeCrosshairMove(param => {
|
|
if (!param.point || !param.time) {
|
|
this.tooltip.hidden = true;
|
|
this.hideOhlc();
|
|
return;
|
|
}
|
|
this.updateOhlc(param.time);
|
|
this.updateLineTooltip(param);
|
|
});
|
|
requestAnimationFrame(() => this.observePlotCanvas());
|
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
|
|
this.scheduleOverlays();
|
|
});
|
|
this.scaleEndListener = () => {
|
|
requestAnimationFrame(() => this.syncCurrentPricePulse());
|
|
};
|
|
el.addEventListener('wheel', this.scaleEndListener, { passive: true });
|
|
el.addEventListener('pointerup', this.scaleEndListener);
|
|
this.installPinchZoom();
|
|
}
|
|
|
|
setBars(bars) {
|
|
if (this.bars[0]?.tf && bars[0]?.tf && this.bars[0].tf !== bars[0].tf) {
|
|
this.manualPriceRange = null;
|
|
}
|
|
this.bars = bars;
|
|
const candleData = bars.map(this.toCandle);
|
|
this.candles.setData(candleData);
|
|
this.updateCurrentPrice(bars[bars.length - 1]?.c);
|
|
this.volume.setData(bars.map(ConfluenceChart.toVolume));
|
|
this.futureSpace.setData(this.timeScaleSlots(bars));
|
|
// 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];
|
|
const step = ConfluenceChart.barInterval(bars) || 60;
|
|
const future = this.futureTimes(bars);
|
|
this.pendingView = {
|
|
from: bars[Math.max(0, bars.length - 160)].t,
|
|
to: future[Math.min(4, future.length - 1)]?.time ?? last.t + step * 5,
|
|
};
|
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
|
}
|
|
this.syncRthLines();
|
|
this.scheduleOverlays(true);
|
|
this.scheduleGeometryDiagnostic();
|
|
}
|
|
|
|
setTrendlineGeometry(geometry) {
|
|
this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy';
|
|
this.trendlineSourceSeries = new Map(
|
|
Object.entries(geometry?.series || {}).map(([tf, value]) => {
|
|
const times = (value.times || []).map(Number);
|
|
const futureTimes = (value.future_times || []).map(Number);
|
|
return [tf, {
|
|
times,
|
|
duration: value.duration == null ? null : Number(value.duration),
|
|
durations: (value.durations || []).map(Number),
|
|
futureTimes,
|
|
futureDurations: (value.future_durations || []).map(Number),
|
|
allTimes: [...times, ...futureTimes],
|
|
}];
|
|
}),
|
|
);
|
|
}
|
|
|
|
setDisplayFutureTimes(times) {
|
|
const next = (times || []).map(Number);
|
|
const prev = this.displayFutureTimes || [];
|
|
if (next.length === prev.length && next.every((time, index) => time === prev[index])) return;
|
|
this.displayFutureTimes = next;
|
|
if (this.bars.length && this.futureSpace) {
|
|
this.futureSpace.setData(this.timeScaleSlots(this.bars));
|
|
}
|
|
}
|
|
|
|
updateTrendlineTime(
|
|
tf, time, duration = null, firstTime = null, previousTime = null,
|
|
futureTimes = [], futureDurations = [],
|
|
) {
|
|
if (this.trendlineGeometryMode !== 'source_tf') return true;
|
|
let source = this.trendlineSourceSeries.get(tf);
|
|
if (!source) {
|
|
source = {
|
|
times: [], duration: duration == null ? null : Number(duration), durations: [],
|
|
futureTimes: [], futureDurations: [],
|
|
allTimes: [],
|
|
};
|
|
this.trendlineSourceSeries.set(tf, source);
|
|
}
|
|
const t = Number(time);
|
|
const lastBeforeUpdate = source.times[source.times.length - 1];
|
|
if (lastBeforeUpdate != null && t > lastBeforeUpdate
|
|
&& previousTime != null && lastBeforeUpdate !== Number(previousTime)) {
|
|
return false;
|
|
}
|
|
if (lastBeforeUpdate == null && previousTime != null) return false;
|
|
if (firstTime != null) {
|
|
const first = Number(firstTime);
|
|
const remove = source.times.findIndex(value => value >= first);
|
|
const count = remove < 0 ? source.times.length : remove;
|
|
if (count > 0) {
|
|
source.times.splice(0, count);
|
|
if (source.duration == null) source.durations.splice(0, count);
|
|
}
|
|
}
|
|
const last = source.times.length - 1;
|
|
if (last < 0 || t > source.times[last]) {
|
|
source.times.push(t);
|
|
if (source.duration == null) source.durations.push(Number(duration));
|
|
} else if (t === source.times[last] && source.duration == null && duration != null) {
|
|
source.durations[last] = Number(duration);
|
|
}
|
|
const nextFuture = futureTimes.map(Number);
|
|
const sameFuture = nextFuture.length === source.futureTimes.length
|
|
&& nextFuture.every((time, index) => time === source.futureTimes[index]);
|
|
if (sameFuture && last >= 0 && t === source.times[last]) return true;
|
|
source.futureTimes = nextFuture;
|
|
source.futureDurations = futureDurations.map(Number);
|
|
source.allTimes = [...source.times, ...source.futureTimes];
|
|
source._filled = null;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Empty slots past the last bar, so the axis can label the projection area.
|
|
*
|
|
* Times are extrapolated by repeating the most recent bar interval, which is
|
|
* exactly what `timeAtIndex` does for the projections themselves. That is
|
|
* approximate across a session break — futures do not trade through the
|
|
* daily halt or the weekend, so a label far to the right drifts from the
|
|
* real clock. Being consistent with the projection matters more than being
|
|
* right in the abstract: an axis that disagreed with the line drawn above it
|
|
* would be worse than one that is uniformly optimistic. Session-accurate
|
|
* projection needs the server's session rules, which are not on the client.
|
|
*/
|
|
futureTimes(bars) {
|
|
if (bars.length < 2) return [];
|
|
const last = bars[bars.length - 1];
|
|
const canonical = this.displayFutureTimes
|
|
.filter(time => time > last.t)
|
|
.slice(0, ConfluenceChart.FUTURE_SLOTS);
|
|
if (canonical.length) return canonical.map(time => ({ time, value: 0 }));
|
|
const step = ConfluenceChart.barInterval(bars);
|
|
if (step <= 0) return [];
|
|
const slots = [];
|
|
for (let index = 1; index <= ConfluenceChart.FUTURE_SLOTS; index += 1) {
|
|
slots.push({ time: last.t + index * step, value: 0 });
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
timeScaleSlots(bars) {
|
|
const step = ConfluenceChart.barInterval(bars);
|
|
if (!(step > 0)) return [];
|
|
const slots = [];
|
|
for (let index = 1; index < bars.length; index += 1) {
|
|
const gap = bars[index].t - bars[index - 1].t;
|
|
if (gap <= step || gap > ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) continue;
|
|
for (let time = bars[index - 1].t + step; time < bars[index].t; time += step) {
|
|
slots.push({ time, value: 0 });
|
|
}
|
|
}
|
|
return [...slots, ...this.futureTimes(bars)];
|
|
}
|
|
|
|
updateBar(bar) {
|
|
const last = this.bars[this.bars.length - 1];
|
|
if (last && bar.t < last.t) return;
|
|
if (!last || bar.t > last.t) {
|
|
this.flushPendingBar();
|
|
this.applyBar(bar);
|
|
return;
|
|
}
|
|
this.pendingBar = bar;
|
|
if (this.pendingBarFrame == null) {
|
|
this.pendingBarFrame = requestAnimationFrame(() => this.flushPendingBar());
|
|
}
|
|
}
|
|
|
|
flushPendingBar() {
|
|
if (this.pendingBarFrame != null) {
|
|
cancelAnimationFrame(this.pendingBarFrame);
|
|
this.pendingBarFrame = null;
|
|
}
|
|
const bar = this.pendingBar;
|
|
this.pendingBar = null;
|
|
if (bar) this.applyBar(bar);
|
|
}
|
|
|
|
applyBar(bar) {
|
|
// update() throws on anything older than the series' last point, which
|
|
// takes the whole app down rather than dropping one stale bar. Ticks made
|
|
// this reachable often enough to matter, so refuse it here as well as at
|
|
// the source: a bar behind the last one has nothing to contribute.
|
|
const last = this.bars[this.bars.length - 1];
|
|
if (last && bar.t < last.t) return;
|
|
const advanced = !last || bar.t > last.t;
|
|
this.keepLivePriceVisible(bar.c);
|
|
this.candles.update(this.toCandle(bar));
|
|
this.updateCurrentPrice(bar.c);
|
|
this.volume.update(ConfluenceChart.toVolume(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);
|
|
if (advanced) this.extendManualLineSeries(last, bar);
|
|
if (advanced) this.syncRthLines();
|
|
if (advanced) this.scheduleOverlays(true);
|
|
}
|
|
|
|
extendManualLineSeries(previous, bar) {
|
|
const step = ConfluenceChart.barInterval(this.bars);
|
|
if (!(step > 0)) return;
|
|
// A session gap invalidates the old future horizon. Rebuild only the small
|
|
// manual-line series; ordinary bars extend their already-owned future tail.
|
|
if (previous && bar.t - previous.t > step) {
|
|
this.futureSpace.setData(this.timeScaleSlots(this.bars));
|
|
for (const level of this.levels) {
|
|
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue;
|
|
this.levelSeries.get(level.id)?.series.setData(this.seriesLineData(level));
|
|
}
|
|
return;
|
|
}
|
|
const futureTime = this.futureTimes(this.bars).at(-1)?.time;
|
|
if (futureTime == null) return;
|
|
this.futureSpace.update({ time: futureTime, value: 0 });
|
|
for (const level of this.levels) {
|
|
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)
|
|
|| futureTime < level.anchor_t || futureTime > (level.cutoff_t ?? Infinity)) continue;
|
|
const entry = this.levelSeries.get(level.id);
|
|
const value = this.linePriceAtIndex(level, this.indexAt(futureTime));
|
|
if (entry && value != null) entry.series.update({ time: futureTime, value });
|
|
}
|
|
}
|
|
|
|
setFibs(fibs) {
|
|
this.fibs = fibs || [];
|
|
this.syncFibLines();
|
|
this.scheduleOverlays(true);
|
|
}
|
|
|
|
static fibPrices(fib) {
|
|
const start = Number(fib.anchor_p);
|
|
const end = start + Number(fib.slope || 0) * (Number(fib.last_t) - Number(fib.anchor_t));
|
|
return ConfluenceChart.FIB_RATIOS.map(item => ({
|
|
...item,
|
|
price: start + (end - start) * item.r,
|
|
}));
|
|
}
|
|
|
|
syncFibLines() {
|
|
if (this.fibPrimitive) this.fibPrimitive.setFibs(this.fibs.filter(fib => !fib.hidden));
|
|
}
|
|
|
|
setComments(comments) {
|
|
this.comments = comments;
|
|
this.scheduleOverlays(true);
|
|
}
|
|
|
|
setCurrentPriceAnimation(enabled) {
|
|
this.animateCurrentPrice = Boolean(enabled);
|
|
this.syncCurrentPricePulse();
|
|
}
|
|
|
|
setAutoScrollLivePrice(enabled) {
|
|
this.autoScrollLivePrice = Boolean(enabled);
|
|
this.keepLivePriceVisible(this.lastCurrentPrice);
|
|
}
|
|
|
|
keepLivePriceVisible(price) {
|
|
if (!this.autoScrollLivePrice || !this.manualPriceRange || price == null) return;
|
|
const plot = this.plotCanvas();
|
|
const y = this.candles.priceToCoordinate(price);
|
|
if (!plot || (y !== null && y >= 0 && y <= plot.clientHeight)) return;
|
|
this.manualPriceRange = null;
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
}
|
|
|
|
updateCurrentPrice(price) {
|
|
if (price == null) return;
|
|
if (this.currentPriceLine && price === this.lastCurrentPrice) {
|
|
this.syncCurrentPricePulse();
|
|
return;
|
|
}
|
|
const options = {
|
|
price,
|
|
color: '#b7771d',
|
|
lineWidth: 2,
|
|
lineStyle: LightweightCharts.LineStyle.LargeDashed,
|
|
axisLabelVisible: true,
|
|
title: '',
|
|
};
|
|
if (this.currentPriceLine) this.currentPriceLine.applyOptions(options);
|
|
else this.currentPriceLine = this.candles.createPriceLine(options);
|
|
|
|
this.lastCurrentPrice = price;
|
|
this.syncCurrentPricePulse();
|
|
}
|
|
|
|
syncCurrentPricePulse() {
|
|
const showFrame = this.animateCurrentPrice && this.lastCurrentPrice != null;
|
|
if (this.currentPricePulse) {
|
|
this.currentPricePulse.hidden = !showFrame;
|
|
this.currentPricePulse.classList.toggle('active', showFrame);
|
|
}
|
|
if (!this.candles || this.lastCurrentPrice == null || !this.chartEl) return;
|
|
const y = this.candles.priceToCoordinate(this.lastCurrentPrice);
|
|
const canvas = this.plotCanvas();
|
|
if (y == null || !canvas) return;
|
|
const chart = this.chartEl.getBoundingClientRect();
|
|
const plot = canvas.getBoundingClientRect();
|
|
if (showFrame && this.currentPricePulse) {
|
|
const scale = Math.max(40, Math.round(chart.right - plot.right));
|
|
this.currentPricePulse.style.left = `${Math.round(plot.right - chart.left + scale - 14)}px`;
|
|
this.currentPricePulse.style.top = `${Math.round(plot.top - chart.top + y - 8)}px`;
|
|
}
|
|
}
|
|
|
|
setCommentHandlers({ onToggle, onMove, onSelect }) {
|
|
this.onCommentToggle = onToggle;
|
|
this.onCommentMove = onMove;
|
|
this.onCommentSelect = onSelect;
|
|
}
|
|
|
|
installPinchZoom() {
|
|
if (!this.chartEl) return;
|
|
const point = touch => ({ x: touch.clientX, y: touch.clientY });
|
|
const separation = touches => ({
|
|
x: Math.abs(touches[1].clientX - touches[0].clientX),
|
|
y: Math.abs(touches[1].clientY - touches[0].clientY),
|
|
});
|
|
const midpoint = touches => ({
|
|
x: (touches[0].clientX + touches[1].clientX) / 2,
|
|
y: (touches[0].clientY + touches[1].clientY) / 2,
|
|
});
|
|
const gap = touches => Math.hypot(
|
|
touches[1].clientX - touches[0].clientX,
|
|
touches[1].clientY - touches[0].clientY,
|
|
);
|
|
this.touchStartListener = event => {
|
|
if (event.touches.length === 1) {
|
|
const touch = event.touches[0];
|
|
this.touchTap = {
|
|
x: touch.clientX,
|
|
y: touch.clientY,
|
|
at: Date.now(),
|
|
annotation: Boolean(event.target.closest?.('.chart-comment')),
|
|
};
|
|
this.touchTwo = null;
|
|
return;
|
|
}
|
|
this.touchTap = null;
|
|
this.touchTwo = null;
|
|
if (event.touches.length !== 2) return;
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
if (!plot) return;
|
|
const touches = [...event.touches];
|
|
const insideChart = touches.every(touch => {
|
|
const p = point(touch);
|
|
return p.x >= plot.left && p.x <= this.chartEl.getBoundingClientRect().right
|
|
&& p.y >= plot.top && p.y <= plot.bottom;
|
|
});
|
|
if (!insideChart) return;
|
|
const distances = separation(touches);
|
|
const mid = midpoint(touches);
|
|
const centerX = mid.x - plot.left;
|
|
const centerY = mid.y - plot.top;
|
|
const margins = this.chart.priceScale('right').options().scaleMargins;
|
|
const top = this.candles.coordinateToPrice(plot.height * margins.top);
|
|
const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom));
|
|
const anchor = this.candles.coordinateToPrice(centerY);
|
|
const timeRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
if ([top, bottom, anchor].some(value => value == null) || !timeRange) return;
|
|
this.touchTwo = {
|
|
kind: null,
|
|
mode: distances.x >= distances.y ? 'time' : 'price',
|
|
distance: Math.max(distances[distances.x >= distances.y ? 'x' : 'y'], 1),
|
|
startGap: Math.max(gap(touches), 1),
|
|
startMid: mid,
|
|
anchor,
|
|
minValue: Math.min(top, bottom),
|
|
maxValue: Math.max(top, bottom),
|
|
timeRange,
|
|
timeAnchor: timeRange.from + (timeRange.to - timeRange.from) * centerX / plot.width,
|
|
plotWidth: plot.width,
|
|
pricePerPx: (Math.max(top, bottom) - Math.min(top, bottom))
|
|
/ Math.max(plot.height * (1 - margins.top - margins.bottom), 1),
|
|
frame: null,
|
|
latest: null,
|
|
};
|
|
event.preventDefault();
|
|
};
|
|
this.touchMoveListener = event => {
|
|
if (event.touches.length === 1 && this.touchTap) {
|
|
const touch = event.touches[0];
|
|
if (Math.hypot(touch.clientX - this.touchTap.x, touch.clientY - this.touchTap.y) > 10) {
|
|
this.touchTap = null;
|
|
this.mousePan = null;
|
|
}
|
|
}
|
|
if (!this.touchTwo || event.touches.length !== 2) return;
|
|
event.preventDefault();
|
|
this.touchTwo.latest = [...event.touches];
|
|
if (this.touchTwo.frame) return;
|
|
this.touchTwo.frame = requestAnimationFrame(() => this.applyTouchTwo());
|
|
};
|
|
this.applyTouchTwo = () => {
|
|
const gesture = this.touchTwo;
|
|
if (!gesture?.latest) return;
|
|
gesture.frame = null;
|
|
const touches = gesture.latest;
|
|
const mid = midpoint(touches);
|
|
if (!gesture.kind) {
|
|
const midDelta = Math.hypot(mid.x - gesture.startMid.x, mid.y - gesture.startMid.y);
|
|
const gapDelta = Math.abs(gap(touches) - gesture.startGap);
|
|
if (midDelta < 10 && gapDelta < 10) return;
|
|
gesture.kind = midDelta > gapDelta ? 'pan' : 'pinch';
|
|
}
|
|
if (gesture.kind === 'pan') {
|
|
const span = gesture.timeRange.to - gesture.timeRange.from;
|
|
const shift = -(mid.x - gesture.startMid.x) / gesture.plotWidth * span;
|
|
this.chart.timeScale().setVisibleLogicalRange({
|
|
from: gesture.timeRange.from + shift,
|
|
to: gesture.timeRange.to + shift,
|
|
});
|
|
const dPrice = (mid.y - gesture.startMid.y) * gesture.pricePerPx;
|
|
this.manualPriceRange = {
|
|
minValue: gesture.minValue + dPrice,
|
|
maxValue: gesture.maxValue + dPrice,
|
|
};
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
} else {
|
|
const distances = separation(touches);
|
|
const { mode, distance, anchor, minValue, maxValue, timeRange, timeAnchor } = gesture;
|
|
const ratio = Math.max(0.1, distances[mode === 'time' ? 'x' : 'y'] / distance);
|
|
if (mode === 'time') {
|
|
this.manualPriceRange = { minValue, maxValue };
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
this.chart.timeScale().setVisibleLogicalRange({
|
|
from: timeAnchor + (timeRange.from - timeAnchor) / ratio,
|
|
to: timeAnchor + (timeRange.to - timeAnchor) / ratio,
|
|
});
|
|
} else {
|
|
this.manualPriceRange = {
|
|
minValue: anchor + (minValue - anchor) / ratio,
|
|
maxValue: anchor + (maxValue - anchor) / ratio,
|
|
};
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
}
|
|
}
|
|
this.scheduleOverlays(true);
|
|
this.updateCurrentPrice(this.lastCurrentPrice);
|
|
};
|
|
this.touchEndListener = event => {
|
|
if (event.touches.length < 2 && this.touchTwo) {
|
|
if (this.touchTwo.frame) cancelAnimationFrame(this.touchTwo.frame);
|
|
this.touchTwo = null;
|
|
}
|
|
if (event.touches.length || !this.touchTap || this.armedTool) return;
|
|
const tap = this.touchTap;
|
|
this.touchTap = null;
|
|
if (tap.annotation || Date.now() - tap.at > 500) return;
|
|
const touch = event.changedTouches[0];
|
|
const chartPoint = touch && this.eventPoint({ clientX: touch.clientX, clientY: touch.clientY });
|
|
if (!chartPoint || !this.withinPlot(chartPoint) || chartPoint.t == null) return;
|
|
this.suppressClickUntil = Date.now() + 500;
|
|
const param = { point: chartPoint, time: chartPoint.t };
|
|
if (this.onChartClick) this.onChartClick(param);
|
|
this.updateLineTooltip(param);
|
|
};
|
|
this.chartEl.addEventListener('touchstart', this.touchStartListener, { passive: false, capture: true });
|
|
this.chartEl.addEventListener('touchmove', this.touchMoveListener, { passive: false, capture: true });
|
|
this.chartEl.addEventListener('touchend', this.touchEndListener, { passive: true, capture: true });
|
|
this.chartEl.addEventListener('touchcancel', this.touchEndListener, { passive: true, capture: true });
|
|
this.priceResetListener = event => {
|
|
if (!this.manualPriceRange) return;
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
if (!plot || event.clientX < plot.right) return;
|
|
this.manualPriceRange = null;
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
};
|
|
this.chartEl.addEventListener('dblclick', this.priceResetListener);
|
|
}
|
|
|
|
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
|
|
// and a floating one must ignore the time scale entirely.
|
|
renderComments() {
|
|
if (!this.commentLayer) return;
|
|
const width = this.overlayLayer.clientWidth;
|
|
const height = this.overlayLayer.clientHeight;
|
|
const seen = new Set();
|
|
for (const comment of this.comments || []) {
|
|
seen.add(comment.id);
|
|
let node = this.commentNodes.get(comment.id);
|
|
if (!node) {
|
|
node = this.buildCommentNode(comment);
|
|
this.commentNodes.set(comment.id, node);
|
|
this.commentLayer.appendChild(node.root);
|
|
}
|
|
node.comment = comment;
|
|
node.root.dataset.drawingId = comment.id;
|
|
node.root.classList.toggle('collapsed', !!comment.collapsed);
|
|
node.root.classList.toggle('floating', !comment.pinned);
|
|
const iconClass = ConfluenceChart.symbolIcons[comment.icon];
|
|
node.root.classList.toggle('symbol', !!iconClass);
|
|
node.collapse.hidden = !!iconClass;
|
|
node.collapseIcon.className = comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress';
|
|
node.grab.hidden = iconClass
|
|
? this.selectedDrawingId !== comment.id
|
|
: comment.pinned;
|
|
if (iconClass) {
|
|
node.icon.className = `fa-solid ${iconClass}`;
|
|
node.label.replaceChildren(node.icon);
|
|
node.root.style.setProperty('--mark-scale', String(comment.scale || 1));
|
|
} else {
|
|
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
|
|
node.root.style.removeProperty('--mark-scale');
|
|
}
|
|
node.root.style.borderColor = comment.color;
|
|
node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f');
|
|
node.root.title = comment.note;
|
|
|
|
if (!comment.pinned) {
|
|
// A fraction of the pane, so it holds its place through any zoom.
|
|
const left = Math.min(Math.max(comment.x * width, 0), Math.max(0, width - node.root.offsetWidth));
|
|
const top = Math.min(Math.max(comment.y * height, 0), Math.max(0, height - node.root.offsetHeight));
|
|
node.root.style.left = `${Math.round(left)}px`;
|
|
node.root.style.top = `${Math.round(top)}px`;
|
|
node.root.classList.remove('off-left', 'off-right');
|
|
continue;
|
|
}
|
|
const placed = this.anchorCoordinate(comment.anchor_t);
|
|
const x = placed.x;
|
|
const y = this.candles.priceToCoordinate(comment.anchor_p);
|
|
// Scrolled past: park it on the edge it went off, pointing that way,
|
|
// rather than letting it vanish and be hard to find again.
|
|
const offLeft = placed.before || (x !== null && x < 0);
|
|
const offRight = placed.after || (x !== null && x > width);
|
|
node.root.classList.toggle('off-left', offLeft && !offRight);
|
|
node.root.classList.toggle('off-right', offRight);
|
|
const boxW = node.root.offsetWidth;
|
|
const boxH = node.root.offsetHeight;
|
|
if (iconClass) {
|
|
const rawX = x == null ? 4 : x - boxW / 2;
|
|
const rawY = y == null ? 24 : y - boxH / 2;
|
|
const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : rawX;
|
|
node.root.style.left = `${Math.round(clampedX)}px`;
|
|
node.root.style.top = `${Math.round(Math.min(Math.max(rawY, 4), height - boxH - 4))}px`;
|
|
} else {
|
|
const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : x;
|
|
node.root.style.left = `${Math.round(clampedX)}px`;
|
|
node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
|
|
}
|
|
}
|
|
for (const [id, node] of this.commentNodes) {
|
|
if (!seen.has(id)) {
|
|
clearTimeout(node.focusTimer);
|
|
node.root.remove();
|
|
this.commentNodes.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Where an anchor time sits on the current timeframe.
|
|
*
|
|
* timeToCoordinate answers only for times that are data points on the series,
|
|
* so a comment placed on a 30m bar returned null the moment the chart
|
|
* switched to 15m or 1h — and null was being read as "off the left edge",
|
|
* which slid every such comment to the far left. The anchor is resolved to
|
|
* the bar that *contains* it instead, which is timeframe-independent: a 09:30
|
|
* note lands on the 09:30 bar at 15m and on the 09:00 bar at 1h.
|
|
*/
|
|
anchorCoordinate(anchorT) {
|
|
if (!this.bars.length) return { x: null, before: false, after: false };
|
|
const x = this.coordinateAtTime(anchorT);
|
|
const width = this.overlayLayer?.clientWidth ?? this.chartEl?.clientWidth ?? 0;
|
|
if (x == null) {
|
|
return {
|
|
x: null,
|
|
before: anchorT < this.bars[0].t,
|
|
after: anchorT > this.bars[this.bars.length - 1].t,
|
|
};
|
|
}
|
|
return { x, before: x < 0, after: x > width };
|
|
}
|
|
|
|
buildCommentNode(comment) {
|
|
const root = document.createElement('div');
|
|
root.className = 'chart-comment';
|
|
const focus = document.createElement('span');
|
|
focus.className = 'chart-comment-focus';
|
|
focus.setAttribute('aria-hidden', 'true');
|
|
const label = document.createElement('span');
|
|
label.className = 'chart-comment-text';
|
|
const icon = document.createElement('i');
|
|
const collapse = document.createElement('button');
|
|
collapse.type = 'button';
|
|
collapse.className = 'chart-comment-collapse';
|
|
collapse.title = 'Collapse or expand';
|
|
collapse.setAttribute('aria-label', 'Collapse or expand comment');
|
|
const collapseIcon = document.createElement('i');
|
|
collapse.appendChild(collapseIcon);
|
|
const grab = document.createElement('button');
|
|
grab.type = 'button';
|
|
grab.className = 'chart-comment-grab';
|
|
grab.title = 'Drag';
|
|
grab.setAttribute('aria-label', 'Drag drawing');
|
|
const grabIcon = document.createElement('i');
|
|
grabIcon.className = 'fa-solid fa-grip-vertical';
|
|
grab.appendChild(grabIcon);
|
|
root.append(focus, collapse, grab, label);
|
|
const node = { root, focus, label, icon, collapse, collapseIcon, grab, comment };
|
|
root.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
this.focusComment(node.comment.id);
|
|
if (this.onCommentSelect) this.onCommentSelect(node.comment);
|
|
});
|
|
collapse.addEventListener('click', event => {
|
|
event.stopPropagation();
|
|
this.focusComment(node.comment.id);
|
|
if (this.onCommentSelect) this.onCommentSelect(node.comment);
|
|
if (this.onCommentToggle) this.onCommentToggle(node.comment);
|
|
});
|
|
grab.addEventListener('click', event => event.stopPropagation());
|
|
// Floating comments move in screen space. Symbols can also be moved while
|
|
// pinned, in which case their bar and tick-snapped price are updated.
|
|
grab.addEventListener('pointerdown', event => {
|
|
if (node.comment.pinned && !node.comment.icon) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.focusComment(node.comment.id);
|
|
if (this.onCommentSelect) this.onCommentSelect(node.comment);
|
|
const bounds = this.plotCanvas().getBoundingClientRect();
|
|
const move = moveEvent => {
|
|
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - root.offsetWidth / 2)}px`;
|
|
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - root.offsetHeight / 2)}px`;
|
|
};
|
|
const up = upEvent => {
|
|
window.removeEventListener('pointermove', move);
|
|
window.removeEventListener('pointerup', up);
|
|
if (node.comment.pinned && node.comment.icon) {
|
|
const point = this.eventPoint(upEvent);
|
|
if (point && this.withinPlot(point) && this.onCommentMove) {
|
|
this.onCommentMove(node.comment, {
|
|
anchor_t: Math.round(point.t),
|
|
anchor_p: ConfluenceChart.snapToTick(point.p),
|
|
});
|
|
} else {
|
|
this.renderComments();
|
|
}
|
|
return;
|
|
}
|
|
const x = (upEvent.clientX - bounds.left - root.offsetWidth / 2) / bounds.width;
|
|
const y = (upEvent.clientY - bounds.top - root.offsetHeight / 2) / bounds.height;
|
|
const clamp = value => Math.min(Math.max(value, 0), 1);
|
|
if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) });
|
|
};
|
|
window.addEventListener('pointermove', move);
|
|
window.addEventListener('pointerup', up);
|
|
});
|
|
return node;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
static isContextLevel(level) {
|
|
return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal';
|
|
}
|
|
|
|
static drawingVisibleOn(drawing, displayedTf) {
|
|
const drawingRank = ConfluenceChart.tfRank[drawing?.tf];
|
|
const displayedRank = ConfluenceChart.tfRank[displayedTf];
|
|
return drawingRank == null || displayedRank == null || drawingRank >= displayedRank;
|
|
}
|
|
|
|
manualLineDisplayStyle(level) {
|
|
const baseWidth = level.line_width || 1;
|
|
const displayedTf = this.bars[0]?.tf;
|
|
const higherTimeframe = level.kind === 'manual' && level.slope
|
|
&& ConfluenceChart.tfRank[level.tf] > ConfluenceChart.tfRank[displayedTf];
|
|
return {
|
|
lineWidth: higherTimeframe ? baseWidth * 2 : baseWidth,
|
|
lineStyle: higherTimeframe
|
|
? LightweightCharts.LineStyle.Dashed
|
|
: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
|
|
higherTimeframe,
|
|
};
|
|
}
|
|
|
|
seriesTitle(level) {
|
|
if (ConfluenceChart.isContextLevel(level)) return '';
|
|
if (level.kind === 'manual') {
|
|
return level.id === this.selectedLineId ? level.label : `#${level.number}`;
|
|
}
|
|
return level.label;
|
|
}
|
|
|
|
// 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, entry] of this.priceLines) {
|
|
if (!wanted.has(id)) {
|
|
entry.host.removePriceLine(entry.line);
|
|
this.priceLines.delete(id);
|
|
}
|
|
}
|
|
for (const level of flat) {
|
|
if (!Number.isFinite(level.anchor_p)) continue;
|
|
const options = {
|
|
price: level.anchor_p,
|
|
color: ConfluenceChart.levelColor(level),
|
|
lineWidth: level.line_width || 1,
|
|
lineStyle: LightweightCharts.LineStyle.Dashed,
|
|
axisLabelVisible: level.kind !== 'horizontal',
|
|
title: this.seriesTitle(level),
|
|
};
|
|
const existing = this.priceLines.get(level.id);
|
|
if (existing) existing.line.applyOptions(options);
|
|
else {
|
|
const host = this.candles;
|
|
this.priceLines.set(level.id, { host, line: host.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])
|
|
.filter(([, value]) => Number.isFinite(value))
|
|
.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 dailyChart = this.bars[0]?.tf === '1d';
|
|
const manualStyle = this.manualLineDisplayStyle(level);
|
|
const options = {
|
|
color: ConfluenceChart.levelColor(level),
|
|
lineWidth: level.kind === 'manual'
|
|
? manualStyle.lineWidth
|
|
: level.line_width || (level.tf === '1d' ? 2 : 1),
|
|
// A daily value is held constant while projected over intraday bars,
|
|
// but on the daily chart each point is a new sample and should connect
|
|
// normally rather than drawing a staircase.
|
|
lineType: isMa && !dailyChart
|
|
? LightweightCharts.LineType.WithSteps
|
|
: LightweightCharts.LineType.Simple,
|
|
lineStyle: level.kind === 'manual'
|
|
? manualStyle.lineStyle
|
|
: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
|
|
priceLineVisible: false,
|
|
lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null,
|
|
title: this.seriesTitle(level),
|
|
// Overlays outside the visible price range must not flatten the candles.
|
|
autoscaleInfoProvider: () => null,
|
|
};
|
|
if (!entry) {
|
|
entry = {
|
|
series: this.chart.addSeries(LightweightCharts.LineSeries,
|
|
{ ...options, priceScaleId: 'right' }),
|
|
};
|
|
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 = level.kind === 'manual' ? this.seriesLineData(level) : this.lineData(level);
|
|
}
|
|
data = (data || []).filter(point => Number.isFinite(point?.value) && point.time != null);
|
|
if (!data.length) {
|
|
if (entry) {
|
|
this.chart.removeSeries(entry.series);
|
|
this.levelSeries.delete(level.id);
|
|
}
|
|
continue;
|
|
}
|
|
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.scheduleOverlays(true);
|
|
this.scheduleGeometryDiagnostic();
|
|
}
|
|
|
|
renderContextLabels() {
|
|
if (!this.contextLabelLayer || !this.candles) return;
|
|
const height = this.plotCanvas()?.getBoundingClientRect().height || 0;
|
|
const labels = this.levels
|
|
.filter(level => !level.hidden && ConfluenceChart.isContextLevel(level))
|
|
.map(level => {
|
|
const points = level.points || [];
|
|
const value = level.current_p ?? points[points.length - 1]?.[1] ?? level.anchor_p;
|
|
const y = value == null ? null : this.candles.priceToCoordinate(value);
|
|
return { level, value, y };
|
|
})
|
|
.filter(item => item.y != null && item.y >= 0 && item.y <= height)
|
|
.sort((a, b) => a.y - b.y);
|
|
|
|
const gap = 18;
|
|
const top = 10;
|
|
const bottom = Math.max(top, height - 10);
|
|
labels.forEach((item, index) => {
|
|
item.placedY = index === 0 ? Math.max(top, item.y) : Math.max(item.y, labels[index - 1].placedY + gap);
|
|
});
|
|
if (labels.length && labels[labels.length - 1].placedY > bottom) {
|
|
const shift = labels[labels.length - 1].placedY - bottom;
|
|
labels.forEach(item => { item.placedY -= shift; });
|
|
}
|
|
|
|
this.contextLabelLayer.replaceChildren(...labels.map(({ level, value, y, placedY }) => {
|
|
const node = document.createElement('div');
|
|
node.className = 'chart-context-label';
|
|
node.textContent = `${level.label} ${Number(value).toFixed(2)}`;
|
|
node.style.top = `${Math.max(top, placedY)}px`;
|
|
node.style.borderColor = ConfluenceChart.levelColor(level);
|
|
return node;
|
|
}));
|
|
}
|
|
|
|
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();
|
|
this.syncCurrentPricePulse();
|
|
});
|
|
}
|
|
|
|
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; }
|
|
|
|
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 ? false : {
|
|
mouseWheel: true,
|
|
pressedMouseMove: true,
|
|
horzTouchDrag: false,
|
|
vertTouchDrag: false,
|
|
},
|
|
handleScale: tool ? false : {
|
|
mouseWheel: true,
|
|
pinch: false,
|
|
axisPressedMouseMove: { time: true, price: true },
|
|
axisDoubleClickReset: { time: true, price: true },
|
|
},
|
|
});
|
|
this.hideSnapDot();
|
|
this.chartEl.classList.toggle('armed', Boolean(tool));
|
|
this.pendingAnchor = null;
|
|
this.clearGesture();
|
|
this.clearLinePreview();
|
|
if (tool) this.attachPointers('tool');
|
|
else this.detachPointers('tool');
|
|
}
|
|
|
|
clearPendingAnchor() {
|
|
if (!this.pendingAnchor) return false;
|
|
this.pendingAnchor = null;
|
|
this.clearLinePreview();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Pixels between the element's left edge and the plot area.
|
|
*
|
|
* Lightweight Charts measures coordinates from the plot, not the element.
|
|
* The helper remains the single source of truth if axes or panes change the
|
|
* plot bounds again.
|
|
*/
|
|
/** Put the overlay container exactly over the plot area. */
|
|
syncOverlayLayer() {
|
|
if (!this.overlayLayer || !this.chartEl) return;
|
|
const canvas = this.plotCanvas();
|
|
if (!canvas) return;
|
|
const chartRect = this.chartEl.getBoundingClientRect();
|
|
const plot = canvas.getBoundingClientRect();
|
|
this.overlayLayer.style.left = `${Math.round(plot.left - chartRect.left)}px`;
|
|
this.overlayLayer.style.top = `${Math.round(plot.top - chartRect.top)}px`;
|
|
this.overlayLayer.style.width = `${Math.round(plot.width)}px`;
|
|
this.overlayLayer.style.height = `${Math.round(plot.height)}px`;
|
|
}
|
|
|
|
/**
|
|
* Keep overlays aligned as Lightweight Charts sizes its internal price scales.
|
|
*
|
|
* The chart element itself does not resize when an axis label changes the
|
|
* plot bounds; the plot canvas does. Observing the canvas catches both initial
|
|
* settling and later digit-width changes.
|
|
*/
|
|
observePlotCanvas() {
|
|
const canvas = this.plotCanvas();
|
|
if (!canvas) return;
|
|
if (canvas !== this.observedPlotCanvas) {
|
|
if (this.plotResizeObserver) this.plotResizeObserver.disconnect();
|
|
this.observedPlotCanvas = canvas;
|
|
this.plotResizeObserver = new ResizeObserver(() => {
|
|
this.syncOverlayLayer();
|
|
this.scheduleOverlays(true);
|
|
if (this.armedTool) this.renderGesture();
|
|
});
|
|
this.plotResizeObserver.observe(canvas);
|
|
}
|
|
this.syncOverlayLayer();
|
|
}
|
|
|
|
plotCanvas() {
|
|
if (!this.chartEl) return null;
|
|
return [...this.chartEl.querySelectorAll('canvas')]
|
|
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0] || null;
|
|
}
|
|
|
|
plotOffsetX() {
|
|
if (!this.chartEl) return 0;
|
|
const canvas = this.plotCanvas();
|
|
if (!canvas) return 0;
|
|
return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left;
|
|
}
|
|
|
|
eventPoint(event) {
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
if (!plot) return null;
|
|
const x = event.clientX - plot.left;
|
|
const y = event.clientY - plot.top;
|
|
const price = this.candles.coordinateToPrice(y);
|
|
if (price == null) return null;
|
|
const time = this.chart.timeScale().coordinateToTime(x);
|
|
const index = time == null ? this.indexAtCoordinate(x) : this.indexAt(Number(time));
|
|
const slotted = index == null ? (time == null ? null : Number(time)) : this.timeAtIndex(Math.round(index));
|
|
return { x, y, p: price, t: slotted };
|
|
}
|
|
|
|
placementAtClient(clientX, clientY) {
|
|
const point = this.eventPoint({ clientX, clientY });
|
|
if (!point || !this.withinPlot(point) || point.t == null) return null;
|
|
return {
|
|
time: point.t,
|
|
price: ConfluenceChart.snapToTick(point.p),
|
|
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
|
|
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
|
|
};
|
|
}
|
|
|
|
indexAtCoordinate(x) {
|
|
if (!this.bars.length) return null;
|
|
const scale = this.chart.timeScale();
|
|
const logical = scale.coordinateToLogical(x);
|
|
const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t);
|
|
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
|
|
if (logical == null || lastLogical == null) return null;
|
|
return this.bars.length - 1 + Number(logical) - Number(lastLogical);
|
|
}
|
|
|
|
coordinateAtIndex(index) {
|
|
if (!this.bars.length) return null;
|
|
const scale = this.chart.timeScale();
|
|
if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t);
|
|
const last = this.bars.length - 1;
|
|
if (index >= last) {
|
|
const lastX = scale.timeToCoordinate(this.bars[last].t);
|
|
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
|
|
return lastLogical == null ? null : scale.logicalToCoordinate(lastLogical + index - last);
|
|
}
|
|
const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index);
|
|
const upper = lower + 1;
|
|
const x1 = scale.timeToCoordinate(this.bars[lower].t);
|
|
const x2 = scale.timeToCoordinate(this.bars[upper].t);
|
|
if (x1 == null || x2 == null) return null;
|
|
return x1 + (x2 - x1) * (index - lower);
|
|
}
|
|
|
|
coordinateAtTime(time) {
|
|
if (!this.bars.length) return null;
|
|
const owned = this.bars.some(bar => bar.t === time)
|
|
|| this.timeScaleSlots(this.bars).some(point => point.time === time);
|
|
if (owned) return this.chart.timeScale().timeToCoordinate(time);
|
|
// Interpolate between the displayed candles' own pixel coordinates. The
|
|
// shared scale may contain foreign timestamps from other series, and its
|
|
// logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h.
|
|
return this.coordinateAtIndex(this.indexAt(time));
|
|
}
|
|
|
|
startToolGesture(event) {
|
|
if (event.button !== 0) return;
|
|
const point = this.eventPoint(event);
|
|
if (!point) return;
|
|
if (!this.armedTool) {
|
|
if (this.withinPlot(point)) {
|
|
this.mousePan = { start: point };
|
|
this.attachPointers('tool');
|
|
}
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
this.gesture = { start: point, end: point };
|
|
this.chartEl.setPointerCapture?.(event.pointerId);
|
|
this.renderGesture();
|
|
}
|
|
|
|
moveToolGesture(event) {
|
|
if (!this.armedTool) {
|
|
this.moveMousePan(event);
|
|
return;
|
|
}
|
|
const point = this.eventPoint(event);
|
|
if (!point) return;
|
|
// The listener is on window, so it fires over the sidebar and the price
|
|
// axis too. Reported from a real session: x=1409 on a 1280-wide chart, which
|
|
// has no time under it — and the snap fell back to the newest bar, throwing
|
|
// the dot to the right-hand edge.
|
|
if (!this.withinPlot(point)) {
|
|
this.hideSnapDot();
|
|
return;
|
|
}
|
|
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point);
|
|
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));
|
|
}
|
|
|
|
postDebugSnap(body) {
|
|
fetch('/api/debug/snap', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json',
|
|
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
|
|
body: JSON.stringify(body),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
scheduleGeometryDiagnostic() {
|
|
if (!ConfluenceChart.diagnosticMode()) return;
|
|
clearTimeout(this._geometryDiagTimer);
|
|
this._geometryDiagTimer = setTimeout(() => this.reportGeometryDiagnostic(), 400);
|
|
}
|
|
|
|
reportGeometryDiagnostic() {
|
|
if (!ConfluenceChart.diagnosticMode() || !this.bars.length) return;
|
|
const now = Date.now();
|
|
if (this._lastGeometryReport && now - this._lastGeometryReport < 2000) return;
|
|
this._lastGeometryReport = now;
|
|
const step = ConfluenceChart.barInterval(this.bars) || 60;
|
|
const space = new Set((this.futureSpace?.data() || []).map(point => Number(point.time)));
|
|
const gaps = [];
|
|
for (let index = 1; index < this.bars.length; index += 1) {
|
|
const delta = this.bars[index].t - this.bars[index - 1].t;
|
|
if (delta <= step) continue;
|
|
let owned = 0;
|
|
for (let time = this.bars[index - 1].t + step; time < this.bars[index].t; time += step) {
|
|
if (space.has(time)) owned += 1;
|
|
}
|
|
gaps.push({
|
|
from: this.bars[index - 1].t,
|
|
to: this.bars[index].t,
|
|
missing: delta / step - 1,
|
|
owned,
|
|
});
|
|
}
|
|
const lines = this.levels
|
|
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
|
|
.slice(0, 24)
|
|
.map(level => {
|
|
const pts = this.levelSeries.get(level.id)?.series.data() || [];
|
|
const segs = [];
|
|
for (let index = 1; index < pts.length; index += 1) {
|
|
const first = pts[index - 1];
|
|
const second = pts[index];
|
|
const x1 = this.chart.timeScale().timeToCoordinate(first.time);
|
|
const x2 = this.chart.timeScale().timeToCoordinate(second.time);
|
|
const y1 = this.candles.priceToCoordinate(first.value);
|
|
const y2 = this.candles.priceToCoordinate(second.value);
|
|
if ([x1, x2, y1, y2].some(value => value == null) || x2 === x1) continue;
|
|
segs.push({
|
|
t1: Number(first.time),
|
|
t2: Number(second.time),
|
|
dtMin: (Number(second.time) - Number(first.time)) / 60,
|
|
dx: +(x2 - x1).toFixed(2),
|
|
slope: +((y2 - y1) / (x2 - x1)).toFixed(4),
|
|
});
|
|
}
|
|
const slopes = segs.map(seg => seg.slope).sort((a, b) => a - b);
|
|
const med = slopes.length ? slopes[Math.floor(slopes.length / 2)] : null;
|
|
const thresh = Math.abs(med || 1) * 0.08;
|
|
return {
|
|
n: level.number,
|
|
tf: level.tf,
|
|
pts: pts.length,
|
|
med,
|
|
bad: segs.filter(seg => med != null && Math.abs(seg.slope - med) > thresh).slice(0, 12),
|
|
};
|
|
});
|
|
this.postDebugSnap({
|
|
kind: 'geometry',
|
|
tf: this.bars[0].tf,
|
|
bars_held: this.bars.length,
|
|
first_bar_t: this.bars[0].t,
|
|
last_bar_t: this.bars[this.bars.length - 1].t,
|
|
chart_w: this.chartEl?.clientWidth,
|
|
chart_h: this.chartEl?.clientHeight,
|
|
note: `gaps ${gaps.length} lines ${lines.length}`,
|
|
gaps: gaps.slice(0, 40),
|
|
lines,
|
|
});
|
|
}
|
|
|
|
showSnapDot(snapped, point = {}) {
|
|
if (snapped.t == null) return this.hideSnapDot();
|
|
const x = this.coordinateAtTime(snapped.t);
|
|
const y = this.candles.priceToCoordinate(snapped.p);
|
|
if (x == null || y == null) return this.hideSnapDot();
|
|
this.snapDot.hidden = false;
|
|
this.snapDot.style.left = `${Math.round(x)}px`;
|
|
this.snapDot.style.top = `${Math.round(y)}px`;
|
|
// Says which it is before you commit, since the extreme decides the side.
|
|
this.snapDot.dataset.side = snapped.snappedSide || '';
|
|
|
|
// Diagnostic mode: report what the client computed so it can be read on
|
|
// the server. Off unless asked for, because the browser is usually on a
|
|
// different machine and this is the only way to see its numbers.
|
|
// turn on: ?diag=1 (remembered) turn off: ?diag=0
|
|
if (ConfluenceChart.diagnosticMode() && snapped.t != null && this.bars.length) {
|
|
const step = this.bars.length > 1
|
|
? this.bars[this.bars.length - 1].t - this.bars[this.bars.length - 2].t : 60;
|
|
const drift = point.t == null ? null : Math.abs(snapped.t - point.t);
|
|
{
|
|
const now = Date.now();
|
|
if (!this._lastSnapReport || now - this._lastSnapReport > 900) {
|
|
this._lastSnapReport = now;
|
|
const body = {
|
|
cursor_t: point.t, cursor_p: point.p, cursor_x: Math.round(point.x),
|
|
snapped_t: snapped.t, snapped_p: snapped.p,
|
|
bars_held: this.bars.length,
|
|
first_bar_t: this.bars[0].t, last_bar_t: this.bars[this.bars.length - 1].t,
|
|
chart_w: this.chartEl.clientWidth,
|
|
chart_h: this.chartEl.clientHeight,
|
|
cursor_y: Math.round(point.y),
|
|
dot_y: this.snapDot && !this.snapDot.hidden
|
|
? Math.round(this.snapDot.getBoundingClientRect().top
|
|
- this.chartEl.getBoundingClientRect().top) + 5
|
|
: null,
|
|
expected_y: Math.round(y),
|
|
bar_low_y: (() => {
|
|
const bar = this.bars.find(candle => candle.t === snapped.t);
|
|
const at = bar && this.candles.priceToCoordinate(bar.l);
|
|
return at == null ? null : Math.round(at);
|
|
})(),
|
|
bar_high_y: (() => {
|
|
const bar = this.bars.find(candle => candle.t === snapped.t);
|
|
const at = bar && this.candles.priceToCoordinate(bar.h);
|
|
return at == null ? null : Math.round(at);
|
|
})(),
|
|
note: point.t == null
|
|
? 'coordinateToTime returned null'
|
|
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
|
};
|
|
this.lastSnapDiagnostic = body;
|
|
this.postDebugSnap(body);
|
|
}
|
|
}
|
|
}
|
|
|
|
const side = snapped.snappedSide === 'resistance' ? 'H' : snapped.snappedSide === 'support' ? 'L' : '';
|
|
const clock = new Date(snapped.t * 1000)
|
|
.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' });
|
|
this.snapLabel.hidden = false;
|
|
this.snapLabel.dataset.side = snapped.snappedSide || '';
|
|
this.snapLabel.textContent = `${snapped.p.toFixed(2)}${side ? ' ' + side : ''} · ${clock}`;
|
|
// Flipped to the left near the right-hand edge so it never leaves the pane.
|
|
const flip = x > this.chartEl.clientWidth - 120;
|
|
this.snapLabel.style.left = `${Math.round(flip ? x - 12 : x + 12)}px`;
|
|
this.snapLabel.style.top = `${Math.round(y)}px`;
|
|
this.snapLabel.style.transform = flip ? 'translate(-100%, -50%)' : 'translate(0, -50%)';
|
|
|
|
if (point.x != null && point.y != null) {
|
|
this.snapLeaderLine.setAttribute('x1', point.x);
|
|
this.snapLeaderLine.setAttribute('y1', point.y);
|
|
this.snapLeaderLine.setAttribute('x2', x);
|
|
this.snapLeaderLine.setAttribute('y2', y);
|
|
this.snapLeader.style.display = '';
|
|
} else {
|
|
this.snapLeader.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
hideSnapDot() {
|
|
if (this.snapDot) this.snapDot.hidden = true;
|
|
if (this.snapLabel) this.snapLabel.hidden = true;
|
|
if (this.snapLeader) this.snapLeader.style.display = 'none';
|
|
}
|
|
|
|
renderPending(a, b) {
|
|
if (this.armedTool === 'fibonacci') {
|
|
this.setFibPreview(a, b);
|
|
return;
|
|
}
|
|
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
|
|
}
|
|
|
|
moveMousePan(event) {
|
|
if (!this.mousePan) return;
|
|
const point = this.eventPoint(event);
|
|
if (!point) return;
|
|
const gesture = this.mousePan;
|
|
if (gesture.minValue == null) {
|
|
const dx = point.x - gesture.start.x;
|
|
const dy = point.y - gesture.start.y;
|
|
if (Math.hypot(dx, dy) < 8) return;
|
|
if (Math.abs(dy) <= Math.abs(dx)) {
|
|
this.mousePan = null;
|
|
return;
|
|
}
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
if (!plot) {
|
|
this.mousePan = null;
|
|
return;
|
|
}
|
|
const margins = this.chart.priceScale('right').options().scaleMargins;
|
|
const top = this.candles.coordinateToPrice(plot.height * margins.top);
|
|
const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom));
|
|
if (top == null || bottom == null) {
|
|
this.mousePan = null;
|
|
return;
|
|
}
|
|
gesture.minValue = Math.min(top, bottom);
|
|
gesture.maxValue = Math.max(top, bottom);
|
|
gesture.pricePerPx = (gesture.maxValue - gesture.minValue)
|
|
/ Math.max(plot.height * (1 - margins.top - margins.bottom), 1);
|
|
}
|
|
const dPrice = (point.y - gesture.start.y) * gesture.pricePerPx;
|
|
this.manualPriceRange = {
|
|
minValue: gesture.minValue + dPrice,
|
|
maxValue: gesture.maxValue + dPrice,
|
|
};
|
|
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
|
|
this.updateCurrentPrice(this.lastCurrentPrice);
|
|
}
|
|
|
|
finishToolGesture(event) {
|
|
const tap = this.mousePan && this.mousePan.minValue == null;
|
|
this.mousePan = null;
|
|
if (!this.armedTool) this.detachPointers('tool');
|
|
if (tap && this.onChartClick) {
|
|
const point = this.eventPoint(event);
|
|
if (point && this.withinPlot(point) && point.t != null) {
|
|
this.suppressClickUntil = Date.now() + 400;
|
|
this.onChartClick({ point, time: point.t, painted: true });
|
|
}
|
|
}
|
|
if (!this.armedTool || !this.gesture) return;
|
|
const tool = this.armedTool;
|
|
const { start, end } = this.gesture;
|
|
this.clearGesture();
|
|
this.chartEl.releasePointerCapture?.(event.pointerId);
|
|
|
|
if (tool === 'comment' || tool === 'symbol') {
|
|
// Both placements come from one gesture: the time and price under the
|
|
// cursor for a pinned comment, and the same spot as a fraction of the
|
|
// pane for a floating one, so the tool decides which without a second
|
|
// interaction.
|
|
this.onToolComplete?.({
|
|
tool,
|
|
time: end.t,
|
|
price: ConfluenceChart.snapToTick(end.p),
|
|
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
|
|
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
|
|
});
|
|
return;
|
|
}
|
|
|
|
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.
|
|
// Deliberate drag or click? At 5px a twitch while pressing the second click
|
|
// counted as a drag, which threw away the anchor placed by the first click
|
|
// and restarted the line under the cursor. Making a pending anchor always
|
|
// win fixed that and broke the other direction: a real press-drag begun
|
|
// after an abandoned click was hijacked by that stale anchor, starting the
|
|
// line far from where the drag did. So the threshold decides, and it is
|
|
// wide enough that only a deliberate drag clears the anchor.
|
|
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD;
|
|
const b = this.snapPoint(end);
|
|
if (!dragged && !this.pendingAnchor) {
|
|
this.pendingAnchor = b;
|
|
this.renderPending(b, b);
|
|
return;
|
|
}
|
|
// A drag says everything it needs to on its own; anything half-placed
|
|
// before it is abandoned rather than silently adopted.
|
|
const a = dragged ? this.snapPoint(start) : this.pendingAnchor;
|
|
this.pendingAnchor = null;
|
|
this.clearLinePreview();
|
|
if (a.t == null || b.t == null) return;
|
|
if (tool !== 'fibonacci' && a.t === b.t) return;
|
|
if (tool === 'fibonacci' && a.p === b.p) return;
|
|
this.onToolComplete?.({ tool, start: a, end: b });
|
|
}
|
|
|
|
/**
|
|
* Trendline anchors snap to bar extremes; levels snap to the tick grid.
|
|
*
|
|
* With snapping on, an anchor always lands on the nearer extreme of the
|
|
* nearest bar — there is no proximity gate. An 8px one used to let the
|
|
* cursor fall between the high and the low and snap to neither, so the
|
|
* preview showed a raw mid-bar price and the side quietly fell back to the
|
|
* dropdown. Which extreme you land on *is* the side: a high is resistance, a
|
|
* low is support, so the choice is made by where you point rather than by a
|
|
* control you have to remember to set. An already-snapped point carries no
|
|
* cursor y, so it is returned untouched rather than re-snapped against NaN.
|
|
*/
|
|
/** Is this point over the plotted area, rather than an axis or the sidebar? */
|
|
withinPlot(point) {
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
if (!plot) return true;
|
|
const width = plot.width;
|
|
const height = plot.height;
|
|
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height;
|
|
}
|
|
|
|
/**
|
|
* The bar nearest a pixel, when the time scale will not name one.
|
|
*
|
|
* coordinateToTime answers null over the right-hand whitespace and the price
|
|
* axis. Treating that as "the newest bar" put the anchor at the live edge
|
|
* however far away the cursor was.
|
|
*/
|
|
barNearestX(x) {
|
|
const index = this.indexAtCoordinate(x);
|
|
if (index != null) return this.timeAtIndex(Math.round(index));
|
|
let best = null;
|
|
const timeScale = this.chart.timeScale();
|
|
for (const bar of this.bars) {
|
|
const coordinate = timeScale.timeToCoordinate(bar.t);
|
|
if (coordinate == null) continue;
|
|
const distance = Math.abs(coordinate - x);
|
|
if (!best || distance < best.distance) best = { distance, t: bar.t };
|
|
}
|
|
return best ? best.t : null;
|
|
}
|
|
|
|
snapPoint(point) {
|
|
const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x));
|
|
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
|
|
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
|
const last = this.bars[this.bars.length - 1];
|
|
if (fallbackT > last.t) {
|
|
const index = Math.round(this.indexAt(fallbackT));
|
|
return { t: this.timeAtIndex(index), p: ConfluenceChart.snapToTick(point.p), snappedSide: null };
|
|
}
|
|
// An already-snapped point carries no cursor position; return it untouched
|
|
// rather than measuring against undefined.
|
|
if (point.y == null) return base;
|
|
|
|
// Horizontal position chooses the bar, vertical position chooses which of
|
|
// its extremes. Nothing else — a nearest-in-2D search was tried and is
|
|
// wrong: sweeping along the bottom of the chart, whichever nearby bar had
|
|
// the lowest low won on total distance, so the dot skipped off the bar
|
|
// under the cursor entirely instead of tracing each bar's low in turn.
|
|
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];
|
|
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
|
}
|
|
|
|
|
|
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 (this.armedTool === 'fibonacci') {
|
|
this.setFibPreview(a, b);
|
|
return;
|
|
}
|
|
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; }
|
|
|
|
setDrawingColorDescriptor(handler) { this.describeDrawingColor = handler; }
|
|
|
|
setLineEndHandler(handler) { this.onLineEnd = handler; }
|
|
|
|
setLineDuplicateHandler(handler) { this.onLineDuplicate = handler; }
|
|
|
|
setLineDeleteHandler(handler) { this.onLineDelete = handler; }
|
|
|
|
setSelectedLine(id) {
|
|
this.selectedLineId = id;
|
|
this.hideContextMenu();
|
|
for (const level of this.levels.filter(value => value.kind === 'manual')) {
|
|
if (ConfluenceChart.isFlat(level)) {
|
|
this.priceLines.get(level.id)?.line.applyOptions({ title: this.seriesTitle(level) });
|
|
} else {
|
|
this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) });
|
|
}
|
|
}
|
|
this.renderAnchorHandles();
|
|
}
|
|
|
|
setSelectedDrawing(id) {
|
|
this.selectedDrawingId = id;
|
|
this.renderComments();
|
|
}
|
|
|
|
focusLine(id) {
|
|
this.setSelectedLine(id);
|
|
const fib = this.fibs.find(item => item.id === id);
|
|
if (fib) {
|
|
this.focusFib(fib);
|
|
return;
|
|
}
|
|
const level = this.levels.find(value => value.id === id && value.kind === 'manual');
|
|
if (!level) return;
|
|
if (ConfluenceChart.isFlat(level)) {
|
|
const y = this.candles.priceToCoordinate(level.anchor_p);
|
|
if (y == null) return;
|
|
this.lineFocus.setAttribute('x1', 0);
|
|
this.lineFocus.setAttribute('y1', y);
|
|
this.lineFocus.setAttribute('x2', this.overlayLayer.getBoundingClientRect().width);
|
|
this.lineFocus.setAttribute('y2', y);
|
|
} else {
|
|
if (this.lineHitTarget.hasAttribute('hidden')) return;
|
|
for (const attribute of ['x1', 'y1', 'x2', 'y2']) {
|
|
this.lineFocus.setAttribute(attribute, this.lineHitTarget.getAttribute(attribute));
|
|
}
|
|
}
|
|
this.lineFocus.setAttribute('stroke', ConfluenceChart.levelColor(level));
|
|
const lineWidth = level.line_width || 2;
|
|
this.lineFocus.style.setProperty('--line-focus-start-width', `${lineWidth + 10}px`);
|
|
this.lineFocus.style.setProperty('--line-focus-width', `${lineWidth}px`);
|
|
this.lineFocus.removeAttribute('hidden');
|
|
this.lineFocus.style.animation = 'none';
|
|
void this.lineFocus.getBoundingClientRect();
|
|
this.lineFocus.style.animation = '';
|
|
clearTimeout(this.lineFocusTimer);
|
|
this.lineFocusTimer = setTimeout(() => this.lineFocus.setAttribute('hidden', ''), 800);
|
|
}
|
|
|
|
focusFib(fib) {
|
|
if (!this.fibFocus || !this.overlayLayer) return;
|
|
this.fibFocus.replaceChildren();
|
|
const width = this.overlayLayer.getBoundingClientRect().width;
|
|
const originX = this.coordinateAtTime(fib.anchor_t);
|
|
const start = originX == null ? 0 : Math.max(0, originX);
|
|
const color = fib.color || '#c8992f';
|
|
const lineWidth = Math.max(1, Number(fib.line_width) || 2);
|
|
this.fibFocus.style.setProperty('--line-focus-start-width', `${lineWidth + 10}px`);
|
|
this.fibFocus.style.setProperty('--line-focus-width', `${lineWidth}px`);
|
|
const y0 = this.candles.priceToCoordinate(Number(fib.anchor_p));
|
|
const endP = Number(fib.anchor_p) + Number(fib.slope || 0)
|
|
* (Number(fib.last_t) - Number(fib.anchor_t));
|
|
const y1 = this.candles.priceToCoordinate(endP);
|
|
if (originX != null && y0 != null && y1 != null) {
|
|
const rail = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
rail.classList.add('chart-line-focus');
|
|
rail.setAttribute('x1', originX);
|
|
rail.setAttribute('x2', originX);
|
|
rail.setAttribute('y1', y0);
|
|
rail.setAttribute('y2', y1);
|
|
rail.setAttribute('stroke', color);
|
|
this.fibFocus.appendChild(rail);
|
|
}
|
|
for (const level of ConfluenceChart.fibPrices(fib)) {
|
|
const y = this.candles.priceToCoordinate(level.price);
|
|
if (y == null) continue;
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
line.classList.add('chart-line-focus');
|
|
line.setAttribute('x1', start);
|
|
line.setAttribute('x2', width);
|
|
line.setAttribute('y1', y);
|
|
line.setAttribute('y2', y);
|
|
line.setAttribute('stroke', color);
|
|
this.fibFocus.appendChild(line);
|
|
}
|
|
this.fibFocus.removeAttribute('hidden');
|
|
for (const line of this.fibFocus.querySelectorAll('line')) {
|
|
line.style.animation = 'none';
|
|
void line.getBoundingClientRect();
|
|
line.style.animation = '';
|
|
}
|
|
clearTimeout(this.fibFocusTimer);
|
|
this.fibFocusTimer = setTimeout(() => this.fibFocus.setAttribute('hidden', ''), 800);
|
|
}
|
|
|
|
focusComment(id) {
|
|
const node = this.commentNodes.get(id);
|
|
if (!node) return;
|
|
clearTimeout(node.focusTimer);
|
|
node.root.classList.remove('focus');
|
|
void node.root.getBoundingClientRect();
|
|
node.root.classList.add('focus');
|
|
node.focusTimer = setTimeout(() => node.root.classList.remove('focus'), 800);
|
|
}
|
|
|
|
setFibPreview(start, end) {
|
|
if (!this.fibPreview || start?.p == null || end?.p == null || start.p === end.p) return;
|
|
if (this.previewLine) this.previewLine.setAttribute('hidden', '');
|
|
this.fibPreview.removeAttribute('hidden');
|
|
this.fibPreview.replaceChildren();
|
|
const width = this.overlayLayer?.clientWidth || this.chartEl.clientWidth;
|
|
const originX = start.t == null ? 0 : (this.coordinateAtTime(start.t) ?? 0);
|
|
const yStart = this.candles.priceToCoordinate(start.p);
|
|
const yEnd = this.candles.priceToCoordinate(end.p);
|
|
if (yStart != null && yEnd != null) {
|
|
const rail = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
rail.setAttribute('x1', originX);
|
|
rail.setAttribute('x2', originX);
|
|
rail.setAttribute('y1', yStart);
|
|
rail.setAttribute('y2', yEnd);
|
|
rail.setAttribute('stroke', '#c8992f');
|
|
rail.setAttribute('stroke-width', 2);
|
|
this.fibPreview.appendChild(rail);
|
|
}
|
|
for (const item of ConfluenceChart.FIB_RATIOS) {
|
|
const y = this.candles.priceToCoordinate(start.p + (end.p - start.p) * item.r);
|
|
if (y == null) continue;
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
line.setAttribute('x1', originX);
|
|
line.setAttribute('x2', width);
|
|
line.setAttribute('y1', y);
|
|
line.setAttribute('y2', y);
|
|
const major = item.r === 0 || item.r === 0.5 || item.r === 0.618 || item.r === 1;
|
|
line.setAttribute('stroke', '#c8992f');
|
|
line.setAttribute('stroke-width', 2);
|
|
if (!major) line.setAttribute('stroke-dasharray', '4 4');
|
|
line.setAttribute('opacity', '0.9');
|
|
this.fibPreview.appendChild(line);
|
|
}
|
|
}
|
|
|
|
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', '');
|
|
if (this.fibPreview) {
|
|
this.fibPreview.setAttribute('hidden', '');
|
|
this.fibPreview.replaceChildren();
|
|
}
|
|
}
|
|
|
|
barAtTime(time) {
|
|
if (time == null || !this.bars.length) return null;
|
|
let lo = 0;
|
|
let hi = this.bars.length - 1;
|
|
while (lo <= hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
const t = this.bars[mid].t;
|
|
if (t === time) return this.bars[mid];
|
|
if (t < time) lo = mid + 1;
|
|
else hi = mid - 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
hideOhlc() {
|
|
if (this.ohlcReadout) this.ohlcReadout.hidden = true;
|
|
}
|
|
|
|
updateOhlc(time) {
|
|
const bar = this.barAtTime(time);
|
|
if (!bar || !this.ohlcReadout) {
|
|
this.hideOhlc();
|
|
return;
|
|
}
|
|
const up = bar.c >= bar.o;
|
|
this.ohlcReadout.hidden = false;
|
|
this.ohlcReadout.classList.toggle('up', up);
|
|
this.ohlcReadout.classList.toggle('down', !up);
|
|
this.ohlcReadout.textContent =
|
|
`O ${bar.o.toFixed(2)} H ${bar.h.toFixed(2)} L ${bar.l.toFixed(2)} C ${bar.c.toFixed(2)}`;
|
|
}
|
|
|
|
updateLineTooltip(param) {
|
|
const id = this.hitTest(param);
|
|
const line = this.levels.find(level => level.id === id);
|
|
this.tooltip.hidden = !line;
|
|
if (!line) return;
|
|
const color = this.describeDrawingColor?.(line.color);
|
|
this.tooltip.textContent = [
|
|
`#${line.number} ${line.label}`,
|
|
this.slopeLabel(line),
|
|
color?.name,
|
|
color?.annotation,
|
|
].filter(Boolean).join('\n');
|
|
this.tooltip.style.left = `${param.point.x + 12}px`;
|
|
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`;
|
|
}
|
|
|
|
paintedLineY(level, time, x) {
|
|
const data = this.levelSeries.get(level.id)?.series.data();
|
|
if (!data?.length) return null;
|
|
const stamp = time == null ? this.chart.timeScale().coordinateToTime(x) : time;
|
|
if (stamp == null) return null;
|
|
const t = Number(stamp);
|
|
let lo = 0;
|
|
let hi = data.length - 1;
|
|
if (t <= Number(data[0].time)) return this.candles.priceToCoordinate(data[0].value);
|
|
if (t >= Number(data[hi].time)) return this.candles.priceToCoordinate(data[hi].value);
|
|
while (hi - lo > 1) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (Number(data[mid].time) <= t) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
const left = data[lo];
|
|
const right = data[hi];
|
|
const span = Number(right.time) - Number(left.time);
|
|
const price = span
|
|
? left.value + (right.value - left.value) * (t - Number(left.time)) / span
|
|
: left.value;
|
|
return this.candles.priceToCoordinate(price);
|
|
}
|
|
|
|
hitTest(param, options = {}) {
|
|
let best = null;
|
|
const visible = this.visibleBarIndexes();
|
|
if (!visible) return null;
|
|
for (const level of this.levels.filter(value =>
|
|
value.kind === 'manual' && ConfluenceChart.isFlat(value) && !value.hidden)) {
|
|
const y = this.candles.priceToCoordinate(level.anchor_p);
|
|
const distance = y == null ? Infinity : Math.abs(y - param.point.y);
|
|
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
|
|
}
|
|
for (const level of this.levels.filter(value =>
|
|
value.kind === 'manual' && !ConfluenceChart.isFlat(value) && !value.hidden)) {
|
|
const lineY = this.paintedLineY(level, param.time, param.point.x);
|
|
const distance = lineY == null ? Infinity : Math.abs(lineY - param.point.y);
|
|
if (distance <= 8 && (!best || distance < best.distance)) best = { id: level.id, distance };
|
|
}
|
|
for (const fib of this.fibs.filter(item => !item.hidden)) {
|
|
const originX = this.coordinateAtTime(fib.anchor_t);
|
|
if (originX == null || param.point.x < originX) continue;
|
|
for (const level of ConfluenceChart.fibPrices(fib)) {
|
|
const y = this.candles.priceToCoordinate(level.price);
|
|
const distance = y == null ? Infinity : Math.abs(y - param.point.y);
|
|
if (distance <= 6 && (!best || distance < best.distance)) best = { id: fib.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 future = this.futureTimes(bars).map(point => point.time);
|
|
if (!future.length) return last;
|
|
let lower = -1;
|
|
while (lower + 1 < future.length && future[lower + 1] <= time) lower += 1;
|
|
if (lower < 0) return last + (time - bars[last].t) / (future[0] - bars[last].t);
|
|
if (future[lower] === time || lower === future.length - 1) return last + lower + 1;
|
|
return last + lower + 1 + (time - future[lower]) / (future[lower + 1] - future[lower]);
|
|
}
|
|
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);
|
|
}
|
|
|
|
sourceSeries(level) {
|
|
if (this.trendlineGeometryMode !== 'source_tf') return null;
|
|
const source = this.trendlineSourceSeries.get(level.tf);
|
|
if (source) return source;
|
|
if (this.bars[0]?.tf !== level.tf) return null;
|
|
const duration = ConfluenceChart.tfSeconds[level.tf] || null;
|
|
return {
|
|
times: this.bars.map(bar => bar.t),
|
|
duration,
|
|
durations: duration == null
|
|
? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400)
|
|
: [],
|
|
futureTimes: [],
|
|
futureDurations: [],
|
|
allTimes: this.bars.map(bar => bar.t),
|
|
};
|
|
}
|
|
|
|
static sourceDuration(source, index) {
|
|
if (source.duration != null) return source.duration;
|
|
if (index < source.times.length) {
|
|
return source.durations[index] ?? source.durations[source.durations.length - 1] ?? null;
|
|
}
|
|
const futureIndex = index - source.times.length;
|
|
return source.futureDurations[futureIndex]
|
|
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
|
|
}
|
|
|
|
static fillShortGaps(source) {
|
|
const times = source?.allTimes || source?.times || [];
|
|
const step = source?.duration;
|
|
if (!(step > 0) || times.length < 2) return times;
|
|
const filled = [times[0]];
|
|
for (let index = 1; index < times.length; index += 1) {
|
|
const prev = filled[filled.length - 1];
|
|
const next = times[index];
|
|
const gap = next - prev;
|
|
if (gap > step && gap <= ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) {
|
|
for (let time = prev + step; time < next; time += step) filled.push(time);
|
|
}
|
|
filled.push(next);
|
|
}
|
|
return filled;
|
|
}
|
|
|
|
static sourceTimes(source) {
|
|
if (!source) return [];
|
|
if (source._filled) return source._filled;
|
|
const filled = ConfluenceChart.fillShortGaps(source);
|
|
source._filled = filled;
|
|
return filled;
|
|
}
|
|
|
|
static timeframeIndexAt(source, time, allowFuture = true) {
|
|
const times = ConfluenceChart.sourceTimes(source);
|
|
if (!times.length) return null;
|
|
let lo = 0;
|
|
let hi = times.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (times[mid] <= time) lo = mid + 1; else hi = mid;
|
|
}
|
|
const lower = lo - 1;
|
|
if (lower >= 0 && times[lower] === time) return lower;
|
|
if (lower < 0) return null;
|
|
const duration = ConfluenceChart.sourceDuration(source, lower);
|
|
const elapsed = time - times[lower];
|
|
if (!(duration > 0) || elapsed < 0) return null;
|
|
if (elapsed > duration && (source.futureTimes?.length
|
|
|| !(allowFuture && lower === times.length - 1))) return null;
|
|
return lower + elapsed / duration;
|
|
}
|
|
|
|
static timeframeTimeAt(source, index) {
|
|
const times = ConfluenceChart.sourceTimes(source);
|
|
if (!times.length || index < 0) return null;
|
|
if (source.futureTimes?.length && index > times.length - 1) return null;
|
|
const lower = Math.min(Math.floor(index), times.length - 1);
|
|
const duration = ConfluenceChart.sourceDuration(source, lower);
|
|
if (!(duration > 0)) return null;
|
|
return Math.round(times[lower] + (index - lower) * duration);
|
|
}
|
|
|
|
sourceLinePriceAt(level, time) {
|
|
const source = this.sourceSeries(level);
|
|
if (!source) return null;
|
|
const start = ConfluenceChart.timeframeIndexAt(source, level.anchor_t);
|
|
const end = ConfluenceChart.timeframeIndexAt(source, level.last_t);
|
|
const target = ConfluenceChart.timeframeIndexAt(source, time);
|
|
if (start == null || end == null || target == null) return null;
|
|
if (start === end) return level.anchor_p;
|
|
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
|
return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start);
|
|
}
|
|
|
|
shiftLineTime(level, time, bars) {
|
|
const source = this.sourceSeries(level);
|
|
if (!source) return this.shiftDisplayedTime(time, bars);
|
|
const index = ConfluenceChart.timeframeIndexAt(source, time);
|
|
return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars);
|
|
}
|
|
|
|
shiftDisplayedTime(time, slots) {
|
|
const scale = this.chart.timeScale();
|
|
const x = this.coordinateAtTime(time);
|
|
const logical = x == null ? null : scale.coordinateToLogical(x);
|
|
const targetX = logical == null ? null : scale.logicalToCoordinate(logical + slots);
|
|
const shifted = targetX == null ? null : scale.coordinateToTime(targetX);
|
|
return shifted == null ? null : Number(shifted);
|
|
}
|
|
|
|
timeAtIndex(index) {
|
|
const bars = this.bars;
|
|
if (bars.length < 2) return bars[0]?.t ?? 0;
|
|
if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t));
|
|
const last = bars.length - 1;
|
|
if (index >= last) {
|
|
const offset = index - last;
|
|
if (offset === 0) return bars[last].t;
|
|
const future = this.futureTimes(bars).map(point => point.time);
|
|
const lower = Math.floor(offset);
|
|
const fraction = offset - lower;
|
|
const t1 = lower === 0 ? bars[last].t : future[lower - 1];
|
|
const t2 = future[lower];
|
|
if (t1 == null) return bars[last].t;
|
|
return t2 == null ? t1 : Math.round(t1 + fraction * (t2 - t1));
|
|
}
|
|
const lower = Math.floor(index);
|
|
const fraction = index - lower;
|
|
return Math.round(bars[lower].t + fraction * (bars[lower + 1].t - bars[lower].t));
|
|
}
|
|
|
|
visibleBarIndexes() {
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
if (!plot) return null;
|
|
const from = this.indexAtCoordinate(0);
|
|
const to = this.indexAtCoordinate(plot.width);
|
|
return from == null || to == null ? null : { from, to };
|
|
}
|
|
|
|
linePriceAtIndex(level, index) {
|
|
if (this.trendlineGeometryMode === 'source_tf') {
|
|
return this.sourceLinePriceAt(level, this.timeAtIndex(index));
|
|
}
|
|
const start = this.indexAt(level.anchor_t);
|
|
const end = this.indexAt(level.last_t);
|
|
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
|
return end === start
|
|
? level.anchor_p
|
|
: level.anchor_p + (endPrice - level.anchor_p) * (index - start) / (end - start);
|
|
}
|
|
|
|
seriesLineData(level) {
|
|
if (!this.bars.length) return [];
|
|
let points;
|
|
if (this.trendlineGeometryMode === 'source_tf') {
|
|
const cutoff = level.cutoff_t ?? Infinity;
|
|
const first = this.bars[0].t;
|
|
const lastBar = this.bars[this.bars.length - 1].t;
|
|
const stamps = new Set();
|
|
const source = this.sourceSeries(level);
|
|
const native = this.bars[0]?.tf === level.tf;
|
|
if (native) {
|
|
for (const bar of this.bars) {
|
|
if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t);
|
|
}
|
|
} else {
|
|
const displayed = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff);
|
|
for (const time of ConfluenceChart.sourceTimes(source) || []) {
|
|
if (time < first || time > lastBar || time < level.anchor_t || time > cutoff) continue;
|
|
let lo = 0;
|
|
let hi = displayed.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (displayed[mid].t < time) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
const bar = displayed[lo] || displayed[displayed.length - 1];
|
|
if (bar) stamps.add(bar.t);
|
|
}
|
|
if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar);
|
|
}
|
|
points = [...stamps]
|
|
.sort((a, b) => a - b)
|
|
.map(time => ({ time, value: this.sourceLinePriceAt(level, time) }))
|
|
.filter(point => Number.isFinite(point.value));
|
|
} else {
|
|
const start = this.indexAt(level.anchor_t);
|
|
const lastBar = this.bars.length - 1;
|
|
const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t);
|
|
const end = Math.min(lastBar, cutoff);
|
|
if (start > end) return [];
|
|
const anchorEnd = this.indexAt(level.last_t);
|
|
const indexes = [start];
|
|
if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd);
|
|
if (end > indexes[indexes.length - 1]) indexes.push(end);
|
|
points = indexes.map(index => ({
|
|
time: this.timeAtIndex(index),
|
|
value: this.linePriceAtIndex(level, index),
|
|
}));
|
|
}
|
|
const cutoff = level.cutoff_t ?? Infinity;
|
|
// futureSpace already owns these timestamps. Giving values to this same
|
|
// LineSeries keeps the historical line and extension in one renderer.
|
|
for (const slot of this.futureTimes(this.bars)) {
|
|
if (slot.time < level.anchor_t || slot.time > cutoff) continue;
|
|
const value = this.linePriceAtIndex(level, this.indexAt(slot.time));
|
|
if (value != null) points.push({ time: slot.time, value });
|
|
}
|
|
return points;
|
|
}
|
|
|
|
renderLineBridges() {
|
|
if (!this.lineBridgeLayer || !this.bars.length) return;
|
|
const firstDisplayed = this.bars[0].t;
|
|
const segment = (level, t1, p1, t2, p2) => {
|
|
const x1 = this.coordinateAtTime(t1);
|
|
const x2 = this.coordinateAtTime(t2);
|
|
const y1 = p1 == null ? null : this.candles.priceToCoordinate(p1);
|
|
const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2);
|
|
if ([x1, x2, y1, y2].some(value => value == null)) return null;
|
|
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
node.setAttribute('x1', x1);
|
|
node.setAttribute('y1', y1);
|
|
node.setAttribute('x2', x2);
|
|
node.setAttribute('y2', y2);
|
|
node.setAttribute('stroke', ConfluenceChart.levelColor(level));
|
|
const style = this.manualLineDisplayStyle(level);
|
|
node.setAttribute('stroke-width', style.lineWidth);
|
|
if (style.higherTimeframe) node.setAttribute('stroke-dasharray', '7 5');
|
|
return node;
|
|
};
|
|
const bridges = [];
|
|
for (const level of this.levels) {
|
|
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue;
|
|
const points = this.seriesLineData(level);
|
|
if (!points.length) continue;
|
|
const priceAt = time => this.trendlineGeometryMode === 'source_tf'
|
|
? this.sourceLinePriceAt(level, time)
|
|
: this.linePriceAtIndex(level, this.indexAt(time));
|
|
const first = points[0];
|
|
if (level.anchor_t >= firstDisplayed && first.time > level.anchor_t) {
|
|
const node = segment(level, level.anchor_t, priceAt(level.anchor_t), first.time, first.value);
|
|
if (node) bridges.push(node);
|
|
}
|
|
const cutoff = level.cutoff_t;
|
|
const last = points[points.length - 1];
|
|
if (cutoff != null && last.time < cutoff) {
|
|
const node = segment(level, last.time, last.value, cutoff, priceAt(cutoff));
|
|
if (node) bridges.push(node);
|
|
}
|
|
}
|
|
this.lineBridgeLayer.replaceChildren(...bridges);
|
|
}
|
|
|
|
renderProjectionDiagnostic() {
|
|
if (!this.projectionDiagnostic) return;
|
|
if (!ConfluenceChart.diagnosticMode() || this.bars.length < 2) {
|
|
this.projectionDiagnostic.hidden = true;
|
|
return;
|
|
}
|
|
const last = this.bars.length - 1;
|
|
const future = this.futureTimes(this.bars)[0];
|
|
const plotHeight = this.plotCanvas()?.clientHeight || 0;
|
|
if (!future || !plotHeight) {
|
|
this.projectionDiagnostic.hidden = true;
|
|
return;
|
|
}
|
|
const slope = (first, second) => {
|
|
if (first.x == null || second.x == null || first.y == null || second.y == null) return null;
|
|
return (second.y - first.y) / (second.x - first.x);
|
|
};
|
|
const lines = this.levels.filter(level => level.kind === 'manual' && !level.hidden
|
|
&& !ConfluenceChart.isFlat(level)).map(level => {
|
|
const previousTime = this.bars[last - 1].t;
|
|
const currentTime = this.bars[last].t;
|
|
const priceAt = time => this.linePriceAtIndex(level, this.indexAt(time));
|
|
const previous = { x: this.chart.timeScale().timeToCoordinate(previousTime),
|
|
y: this.candles.priceToCoordinate(priceAt(previousTime)), p: priceAt(previousTime) };
|
|
const current = { x: this.chart.timeScale().timeToCoordinate(currentTime),
|
|
y: this.candles.priceToCoordinate(priceAt(currentTime)), p: priceAt(currentTime) };
|
|
const next = { x: this.chart.timeScale().timeToCoordinate(future.time),
|
|
y: this.candles.priceToCoordinate(priceAt(future.time)), p: priceAt(future.time) };
|
|
const series = this.levelSeries.get(level.id)?.series.data()
|
|
.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);
|
|
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;
|
|
}
|
|
|
|
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) {
|
|
// 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,
|
|
}));
|
|
}
|
|
|
|
handleLevel(id) {
|
|
return this.levels.find(value => value.id === id)
|
|
|| this.fibs.find(item => item.id === id)
|
|
|| null;
|
|
}
|
|
|
|
fibAsHandleLevel() {
|
|
const fib = this.fibs.find(item => item.id === this.selectedLineId && !item.hidden);
|
|
if (!fib) return null;
|
|
if (fib.slope == null) fib.slope = 0;
|
|
return fib;
|
|
}
|
|
|
|
renderAnchorHandles() {
|
|
const level = this.levels.find(
|
|
value => value.id === this.selectedLineId && value.kind === 'manual' && !value.hidden,
|
|
) || this.fibAsHandleLevel();
|
|
if (!level || !this.anchorHandles.length) {
|
|
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
|
this.lineHitTarget?.setAttribute('hidden', '');
|
|
return;
|
|
}
|
|
if (ConfluenceChart.isFlat(level)) {
|
|
const plot = this.plotCanvas()?.getBoundingClientRect();
|
|
const y = this.candles.priceToCoordinate(level.anchor_p);
|
|
const handle = this.anchorHandles[0];
|
|
this.lineHitTarget.setAttribute('hidden', '');
|
|
this.anchorHandles[1].setAttribute('hidden', '');
|
|
if (!plot || y == null) {
|
|
handle.setAttribute('hidden', '');
|
|
return;
|
|
}
|
|
handle.removeAttribute('hidden');
|
|
handle.setAttribute('cx', Math.max(20, plot.width - 24));
|
|
handle.setAttribute('cy', y);
|
|
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
|
|
return;
|
|
}
|
|
const points = [
|
|
{ time: level.anchor_t, value: level.anchor_p },
|
|
{
|
|
time: level.last_t,
|
|
value: level.anchor_p + level.slope * (level.last_t - level.anchor_t),
|
|
},
|
|
];
|
|
if (level.kind === 'fibonacci') {
|
|
const x = this.coordinateAtTime(level.anchor_t);
|
|
const y1 = this.candles.priceToCoordinate(points[0].value);
|
|
const y2 = this.candles.priceToCoordinate(points[1].value);
|
|
if ([x, y1, y2].some(value => value == null)) {
|
|
this.lineHitTarget.setAttribute('hidden', '');
|
|
} else {
|
|
this.lineHitTarget.removeAttribute('hidden');
|
|
this.lineHitTarget.setAttribute('points', `${x},${y1} ${x},${y2}`);
|
|
this.lineHitTarget.setAttribute('x1', x);
|
|
this.lineHitTarget.setAttribute('y1', y1);
|
|
this.lineHitTarget.setAttribute('x2', x);
|
|
this.lineHitTarget.setAttribute('y2', y2);
|
|
}
|
|
points.forEach((point, index) => {
|
|
const handle = this.anchorHandles[index];
|
|
const handleX = this.coordinateAtTime(point.time);
|
|
const handleY = this.candles.priceToCoordinate(point.value);
|
|
if (handleX == null || handleY == null) handle.setAttribute('hidden', '');
|
|
else {
|
|
handle.removeAttribute('hidden');
|
|
handle.setAttribute('cx', handleX);
|
|
handle.setAttribute('cy', handleY);
|
|
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const visible = this.visibleBarIndexes();
|
|
const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
|
|
const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd);
|
|
const hitStart = this.indexAt(level.anchor_t);
|
|
const hitIndexes = [];
|
|
if (hitEnd != null) {
|
|
for (let index = Math.max(hitStart, visible?.from ?? hitStart); index <= hitEnd; index += 1) {
|
|
hitIndexes.push(index);
|
|
}
|
|
if (!hitIndexes.length || hitIndexes[hitIndexes.length - 1] < hitEnd) hitIndexes.push(hitEnd);
|
|
}
|
|
const hitPoints = hitIndexes.map(index => ({
|
|
x: index == null ? null : this.coordinateAtIndex(index),
|
|
y: index == null ? null : this.linePriceAtIndex(level, index),
|
|
})).map(point => ({
|
|
x: point.x,
|
|
y: point.y == null ? null : this.candles.priceToCoordinate(point.y),
|
|
})).filter(point => point.x != null && point.y != null);
|
|
// Daily future slots can include non-session dates that canonical source
|
|
// geometry refuses to price. Omit those points; one null must not disable
|
|
// selection and body dragging for the entire otherwise-valid line.
|
|
if (hitPoints.length < 2) {
|
|
this.lineHitTarget.setAttribute('hidden', '');
|
|
} else {
|
|
this.lineHitTarget.removeAttribute('hidden');
|
|
const first = hitPoints[0];
|
|
const last = hitPoints[hitPoints.length - 1];
|
|
this.lineHitTarget.setAttribute('points', hitPoints.map(point => `${point.x},${point.y}`).join(' '));
|
|
// Kept for diagnostics and the existing drag test, though the polyline's
|
|
// actual hit region follows every sampled point.
|
|
this.lineHitTarget.setAttribute('x1', first.x);
|
|
this.lineHitTarget.setAttribute('y1', first.y);
|
|
this.lineHitTarget.setAttribute('x2', last.x);
|
|
this.lineHitTarget.setAttribute('y2', last.y);
|
|
}
|
|
points.forEach((point, index) => {
|
|
const x = this.coordinateAtTime(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));
|
|
});
|
|
}
|
|
|
|
startLineDrag(event) {
|
|
if (event.button !== 0) return;
|
|
const level = this.handleLevel(this.selectedLineId);
|
|
const point = this.eventPoint(event);
|
|
if (!level || level.geometry_resolved === false || !point || point.t == null) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.draggingLine = {
|
|
id: level.id,
|
|
start: point,
|
|
original: { ...level },
|
|
moved: false,
|
|
};
|
|
this.attachPointers('line');
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
}
|
|
|
|
moveLine(event) {
|
|
if (!this.draggingLine) return;
|
|
const point = this.eventPoint(event);
|
|
const level = this.handleLevel(this.draggingLine.id);
|
|
if (!point || point.t == null || !this.withinPlot(point) || !level) return;
|
|
event.preventDefault();
|
|
const { original, start } = this.draggingLine;
|
|
const source = this.sourceSeries(original);
|
|
const sourcePoint = source && ConfluenceChart.timeframeIndexAt(source, point.t);
|
|
const sourceStart = source && ConfluenceChart.timeframeIndexAt(source, start.t);
|
|
const indexShift = Math.round(
|
|
sourcePoint != null && sourceStart != null
|
|
? sourcePoint - sourceStart
|
|
: this.indexAt(point.t) - this.indexAt(start.t),
|
|
);
|
|
const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
|
|
const anchorT = source
|
|
? this.shiftLineTime(original, original.anchor_t, indexShift)
|
|
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
|
|
const lastT = source
|
|
? this.shiftLineTime(original, original.last_t, indexShift)
|
|
: this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
|
|
if (anchorT == null || lastT == null) return;
|
|
const cutoffT = original.cutoff_t == null
|
|
? null
|
|
: source
|
|
? this.shiftLineTime(original, original.cutoff_t, indexShift)
|
|
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
|
|
if (original.cutoff_t != null && cutoffT == null) return;
|
|
const endPrice = original.anchor_p
|
|
+ original.slope * (original.last_t - original.anchor_t) + priceShift;
|
|
level.anchor_t = anchorT;
|
|
level.last_t = lastT;
|
|
level.anchor_p = original.anchor_p + priceShift;
|
|
level.slope = (endPrice - level.anchor_p) / (lastT - anchorT);
|
|
if (original.cutoff_t != null) {
|
|
level.cutoff_t = cutoffT;
|
|
}
|
|
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
|
|
const entry = this.levelSeries.get(level.id);
|
|
if (entry) entry.series.setData(this.seriesLineData(level));
|
|
if (level.kind === 'fibonacci') this.syncFibLines();
|
|
this.renderAnchorHandles();
|
|
this.renderLineBridges();
|
|
}
|
|
|
|
finishLineDrag(event) {
|
|
if (!this.draggingLine) return;
|
|
event.preventDefault();
|
|
const level = this.handleLevel(this.draggingLine.id);
|
|
const moved = this.draggingLine.moved;
|
|
const original = this.draggingLine.original;
|
|
this.draggingLine = null;
|
|
this.detachPointers('line');
|
|
if (moved && level && this.onLineChange) this.onLineChange({ ...level }, { ...original });
|
|
}
|
|
|
|
startAnchorDrag(event, anchor) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const level = this.handleLevel(this.selectedLineId);
|
|
this.draggingAnchor = {
|
|
id: this.selectedLineId,
|
|
anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor,
|
|
original: level ? { ...level } : null,
|
|
};
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
this.attachPointers('anchor');
|
|
}
|
|
|
|
moveAnchor(event) {
|
|
if (!this.draggingAnchor || !this.chartEl) return;
|
|
const level = this.handleLevel(this.draggingAnchor.id);
|
|
if (!level || !this.bars.length) return;
|
|
const point = this.eventPoint(event);
|
|
if (!point || !this.withinPlot(point)) {
|
|
this.hideSnapDot();
|
|
return;
|
|
}
|
|
const snapped = this.snapToBars
|
|
? this.snapPoint(point)
|
|
: { ...point, p: ConfluenceChart.snapToTick(point.p) };
|
|
if (snapped.t == null || snapped.p == null) return;
|
|
const time = snapped.t;
|
|
const price = snapped.p;
|
|
if (this.snapToBars) this.showSnapDot(snapped, point);
|
|
if (this.draggingAnchor.anchor === 'price') {
|
|
level.anchor_p = price;
|
|
this.priceLines.get(level.id)?.line.applyOptions({ price });
|
|
this.renderAnchorHandles();
|
|
return;
|
|
}
|
|
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
|
|
const fib = level.kind === 'fibonacci';
|
|
if (this.draggingAnchor.anchor === 'start') {
|
|
if (!fib && time >= level.last_t) return;
|
|
if (fib && time === level.last_t) return;
|
|
level.anchor_t = time;
|
|
level.anchor_p = price;
|
|
level.slope = (secondPrice - price) / (level.last_t - time);
|
|
} else {
|
|
if (!fib && time <= level.anchor_t) return;
|
|
if (fib && 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.seriesLineData(level));
|
|
if (fib) this.syncFibLines();
|
|
this.renderAnchorHandles();
|
|
this.renderLineBridges();
|
|
}
|
|
|
|
finishAnchorDrag(event) {
|
|
if (!this.draggingAnchor) return;
|
|
event.preventDefault();
|
|
const original = this.draggingAnchor.original;
|
|
const level = this.handleLevel(this.draggingAnchor.id);
|
|
this.draggingAnchor = null;
|
|
this.detachPointers('anchor');
|
|
this.hideSnapDot();
|
|
if (level && original && this.onLineChange && (
|
|
level.anchor_t !== original.anchor_t
|
|
|| level.anchor_p !== original.anchor_p
|
|
|| level.slope !== original.slope
|
|
|| level.last_t !== original.last_t
|
|
)) {
|
|
this.onLineChange({ ...level }, { ...original });
|
|
}
|
|
}
|
|
|
|
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 point = this.eventPoint(event);
|
|
if (!point || !this.withinPlot(point) || point.t == null) return;
|
|
const lastBar = this.bars[this.bars.length - 1];
|
|
const cutoff = point.t > lastBar.t
|
|
? Math.round(point.t)
|
|
: this.bars.reduce((nearest, bar) =>
|
|
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
|
).t;
|
|
this.contextLineId = level.id;
|
|
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
|
|
this.contextEndButton.hidden = this.contextCutoff == null;
|
|
this.contextExtendButton.hidden = level.cutoff_t == null;
|
|
this.contextMenu.hidden = false;
|
|
const plot = this.plotCanvas().getBoundingClientRect();
|
|
this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`;
|
|
this.contextMenu.style.top = `${Math.max(4, Math.min(point.y, plot.height - this.contextMenu.offsetHeight - 4))}px`;
|
|
}
|
|
|
|
hideContextMenu() {
|
|
if (this.contextMenu) this.contextMenu.hidden = true;
|
|
this.contextCutoff = null;
|
|
this.contextLineId = null;
|
|
}
|
|
|
|
dismissContextMenu() {
|
|
if (!this.contextMenu || this.contextMenu.hidden) return false;
|
|
this.hideContextMenu();
|
|
return true;
|
|
}
|
|
|
|
endSelectedLineHere() {
|
|
const level = this.levels.find(value => value.id === this.selectedLineId);
|
|
if (!level || this.contextCutoff == null) return;
|
|
const previousCutoff = level.cutoff_t;
|
|
level.cutoff_t = this.contextCutoff;
|
|
const entry = this.levelSeries.get(level.id);
|
|
if (entry) entry.series.setData(this.seriesLineData(level));
|
|
this.renderLineBridges();
|
|
this.hideContextMenu();
|
|
if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff);
|
|
}
|
|
|
|
extendSelectedLine() {
|
|
const level = this.levels.find(value => value.id === this.selectedLineId);
|
|
if (!level || level.cutoff_t == null) return;
|
|
const previousCutoff = level.cutoff_t;
|
|
level.cutoff_t = null;
|
|
const entry = this.levelSeries.get(level.id);
|
|
if (entry) entry.series.setData(this.seriesLineData(level));
|
|
this.renderLineBridges();
|
|
this.hideContextMenu();
|
|
if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff);
|
|
}
|
|
|
|
// Tinted by the bar's own direction, muted so the candles stay the subject.
|
|
static toVolume(bar) {
|
|
return {
|
|
time: bar.t,
|
|
value: bar.v,
|
|
color: bar.c >= bar.o ? 'rgba(32,184,166,.38)' : 'rgba(239,91,63,.38)',
|
|
};
|
|
}
|
|
|
|
setRthEnabled(enabled) {
|
|
this.rthEnabled = enabled;
|
|
this.syncRthLines();
|
|
}
|
|
|
|
syncRthLines() {
|
|
if (!this.rthPrimitive) return;
|
|
if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') {
|
|
this.rthPrimitive.setMarks([]);
|
|
return;
|
|
}
|
|
const first = this.bars[0].t;
|
|
const last = this.bars[this.bars.length - 1].t;
|
|
const marks = [];
|
|
const seen = new Set();
|
|
for (const mark of ConfluenceChart.rthInstants(first, last)) {
|
|
const time = this.barContaining(mark.t);
|
|
if (time == null || seen.has(time)) continue;
|
|
seen.add(time);
|
|
marks.push({ t: time, kind: mark.kind });
|
|
}
|
|
this.rthPrimitive.setMarks(marks);
|
|
}
|
|
|
|
barContaining(time) {
|
|
const bars = this.bars;
|
|
if (!bars.length || time < bars[0].t) return null;
|
|
let lo = 0;
|
|
let hi = bars.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (bars[mid].t <= time) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return bars[lo].t;
|
|
}
|
|
|
|
static easternParts(epochSec) {
|
|
const parts = Object.fromEntries(
|
|
new Intl.DateTimeFormat('en-US', {
|
|
timeZone: 'America/New_York',
|
|
hour12: false,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).formatToParts(new Date(epochSec * 1000))
|
|
.filter(part => part.type !== 'literal')
|
|
.map(part => [part.type, part.value]),
|
|
);
|
|
return {
|
|
year: Number(parts.year),
|
|
month: Number(parts.month),
|
|
day: Number(parts.day),
|
|
hour: Number(parts.hour) % 24,
|
|
minute: Number(parts.minute),
|
|
};
|
|
}
|
|
|
|
static epochAtEastern(year, month, day, hour, minute) {
|
|
let guess = Date.UTC(year, month - 1, day, hour + 4, minute);
|
|
for (let i = 0; i < 4; i += 1) {
|
|
const got = ConfluenceChart.easternParts(guess / 1000);
|
|
const delta = Date.UTC(year, month - 1, day, hour, minute)
|
|
- Date.UTC(got.year, got.month - 1, got.day, got.hour, got.minute);
|
|
if (delta === 0) break;
|
|
guess += delta;
|
|
}
|
|
return Math.floor(guess / 1000);
|
|
}
|
|
|
|
static rthInstants(fromT, toT) {
|
|
const marks = [];
|
|
const seen = new Set();
|
|
let cursor = fromT - 12 * 3600;
|
|
const stop = toT + 12 * 3600;
|
|
while (cursor <= stop) {
|
|
const parts = ConfluenceChart.easternParts(cursor);
|
|
const key = `${parts.year}-${parts.month}-${parts.day}`;
|
|
const weekday = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
|
|
if (!seen.has(key) && weekday !== 0 && weekday !== 6) {
|
|
seen.add(key);
|
|
marks.push({
|
|
t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 9, 30),
|
|
kind: 'open',
|
|
});
|
|
marks.push({
|
|
t: ConfluenceChart.epochAtEastern(parts.year, parts.month, parts.day, 16, 0),
|
|
kind: 'close',
|
|
});
|
|
}
|
|
cursor += 12 * 3600;
|
|
}
|
|
return marks.filter(mark => mark.t >= fromT - 3600 && mark.t <= toT + 3600);
|
|
}
|
|
|
|
slopePerHour(line) {
|
|
const source = this.sourceSeries(line);
|
|
if (source) {
|
|
const start = ConfluenceChart.timeframeIndexAt(source, line.anchor_t);
|
|
const end = ConfluenceChart.timeframeIndexAt(source, line.last_t);
|
|
const duration = start == null || end == null
|
|
? null : ConfluenceChart.sourceDuration(source, Math.floor((start + end) / 2));
|
|
if (duration > 0 && end !== start) {
|
|
const endPrice = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
|
|
return (endPrice - line.anchor_p) / (end - start) * 3600 / duration;
|
|
}
|
|
}
|
|
return line.slope * 3600;
|
|
}
|
|
|
|
slopeLabel(line) {
|
|
if (ConfluenceChart.isFlat(line) || !line.slope) return '';
|
|
const perHour = this.slopePerHour(line);
|
|
const decimals = Math.abs(perHour) >= 10 ? 1 : 2;
|
|
return `${perHour > 0 ? '+' : ''}${perHour.toFixed(decimals)} /h`;
|
|
}
|
|
|
|
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.plotResizeObserver) this.plotResizeObserver.disconnect();
|
|
if (this.overlayFrame != null) cancelAnimationFrame(this.overlayFrame);
|
|
this.detachPointers('tool');
|
|
this.detachPointers('anchor');
|
|
this.detachPointers('line');
|
|
clearTimeout(this.lineFocusTimer);
|
|
clearTimeout(this.fibFocusTimer);
|
|
for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer);
|
|
if (this.chartEl && this.scaleEndListener) {
|
|
this.chartEl.removeEventListener('wheel', this.scaleEndListener);
|
|
this.chartEl.removeEventListener('pointerup', this.scaleEndListener);
|
|
}
|
|
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.touchStartListener) {
|
|
this.chartEl.removeEventListener('touchstart', this.touchStartListener, true);
|
|
this.chartEl.removeEventListener('touchmove', this.touchMoveListener, true);
|
|
this.chartEl.removeEventListener('touchend', this.touchEndListener, true);
|
|
this.chartEl.removeEventListener('touchcancel', this.touchEndListener, true);
|
|
this.chartEl.removeEventListener('dblclick', this.priceResetListener);
|
|
}
|
|
if (this.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener);
|
|
if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener);
|
|
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.FIB_RATIOS = [
|
|
{ r: 0, label: '0' },
|
|
{ r: 0.236, label: '23.6' },
|
|
{ r: 0.382, label: '38.2' },
|
|
{ r: 0.5, label: '50' },
|
|
{ r: 0.618, label: '61.8' },
|
|
{ r: 0.786, label: '78.6' },
|
|
{ r: 1, label: '100' },
|
|
];
|
|
|
|
ConfluenceChart.tfColors = {
|
|
'1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b',
|
|
'30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073',
|
|
};
|
|
|
|
ConfluenceChart.tfSeconds = {
|
|
'1m': 60, '2m': 120, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600,
|
|
};
|
|
|
|
ConfluenceChart.tfRank = {
|
|
'1m': 1, '2m': 2, '5m': 3, '15m': 4, '30m': 5, '1h': 6, '1d': 7,
|
|
};
|
|
|
|
// 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.symbolIcons = {
|
|
'arrow-up': 'fa-arrow-up',
|
|
'arrow-down': 'fa-arrow-down',
|
|
'face-smile': 'fa-face-smile',
|
|
'hand-point-right': 'fa-hand-point-right',
|
|
skull: 'fa-skull',
|
|
'face-laugh-squint': 'fa-face-laugh-squint',
|
|
'champagne-glasses': 'fa-champagne-glasses',
|
|
'arrow-left': 'fa-arrow-left',
|
|
'arrow-right': 'fa-arrow-right',
|
|
hand: 'fa-hand',
|
|
'right-to-bracket': 'fa-right-to-bracket',
|
|
play: 'fa-play',
|
|
};
|
|
|
|
ConfluenceChart.levelColor = level =>
|
|
level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf];
|
|
|
|
window.ConfluenceChart = ConfluenceChart;
|