Ticks were force-redrawing trendline overlays and, in diagnostic mode, sampling canvas pixels. That maxed CPU. Also document production log and capture access for agents.
3202 lines
130 KiB
JavaScript
3202 lines
130 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.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.hidden = true;
|
|
this.overlayLayer.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);
|
|
}
|
|
|
|
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) {
|
|
this.displayFutureTimes = (times || []).map(Number);
|
|
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);
|
|
}
|
|
source.futureTimes = futureTimes.map(Number);
|
|
source.futureDurations = futureDurations.map(Number);
|
|
source.allTimes = [...source.times, ...source.futureTimes];
|
|
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) {
|
|
// 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;
|
|
const options = {
|
|
price,
|
|
color: '#b7771d',
|
|
lineWidth: 1,
|
|
lineStyle: LightweightCharts.LineStyle.Dotted,
|
|
axisLabelVisible: true,
|
|
title: '',
|
|
};
|
|
if (this.currentPriceLine) this.currentPriceLine.applyOptions(options);
|
|
else this.currentPriceLine = this.candles.createPriceLine(options);
|
|
|
|
this.lastCurrentPrice = price;
|
|
this.syncCurrentPricePulse();
|
|
}
|
|
|
|
syncCurrentPricePulse() {
|
|
if (!this.currentPricePulse) return;
|
|
const show = this.animateCurrentPrice && this.lastCurrentPrice != null;
|
|
this.currentPricePulse.hidden = !show;
|
|
this.currentPricePulse.classList.toggle('active', show);
|
|
if (!show || !this.candles || this.lastCurrentPrice == null) return;
|
|
const y = this.candles.priceToCoordinate(this.lastCurrentPrice);
|
|
if (y != null) this.currentPricePulse.style.top = `${Math.round(y)}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) {
|
|
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])
|
|
.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);
|
|
}
|
|
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);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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;
|
|
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(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
this.mousePan = null;
|
|
if (!this.armedTool) this.detachPointers('tool');
|
|
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`;
|
|
}
|
|
|
|
hitTest(param) {
|
|
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 startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
|
|
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
|
|
const endIndex = Math.min(naturalEnd, visible.to);
|
|
const pointIndex = this.indexAtCoordinate(param.point.x);
|
|
if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue;
|
|
const linePrice = this.linePriceAtIndex(level, pointIndex);
|
|
const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice);
|
|
if (lineY == null) continue;
|
|
const distance = Math.abs(lineY - param.point.y);
|
|
if (distance <= 6 && (!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 sourceTimes(source) {
|
|
return source?.allTimes || source?.times || [];
|
|
}
|
|
|
|
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;
|
|
points = this.bars
|
|
.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff)
|
|
.map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) }))
|
|
.filter(point => point.value != null);
|
|
} 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;
|