Paint session H/L in the candle primitive so they track price zoom.

DOM marks only moved on overlay redraw, so a price-axis drag left them
stuck until something else refreshed.
This commit is contained in:
Chris Amow 2026-09-01 22:47:16 -05:00
parent bc9fed764a
commit 72bcd0fa61
2 changed files with 93 additions and 61 deletions

View file

@ -195,6 +195,87 @@ class FibLevelsPrimitive {
} }
} }
class SessionRangePrimitive {
constructor() {
this._high = null;
this._low = null;
this._enabled = true;
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;
}
setRange(high, low, enabled) {
this._high = high;
this._low = low;
this._enabled = Boolean(enabled);
if (this._requestUpdate) this._requestUpdate();
}
updateAllViews() {
this._view.update();
}
paneViews() {
return [this._view];
}
_sync() {
this._items = [];
if (!this._enabled || !this._series || this._high == null || this._low == null) return;
const highY = this._series.priceToCoordinate(this._high);
const lowY = this._series.priceToCoordinate(this._low);
if (highY != null) this._items.push({ key: 'H', value: this._high, y: highY, color: '#27825c' });
if (lowY != null) this._items.push({ key: 'L', value: this._low, y: lowY, color: '#bd4545' });
if (this._items.length === 2 && Math.abs(this._items[0].y - this._items[1].y) < 14) {
this._items[0].y -= 7;
this._items[1].y += 7;
}
}
_draw(target) {
if (!this._items.length) return;
target.useMediaCoordinateSpace(scope => {
const ctx = scope.context;
const right = scope.mediaSize.width;
const height = scope.mediaSize.height;
ctx.save();
ctx.font = '9px "IBM Plex Mono", ui-monospace, monospace';
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (const item of this._items) {
if (item.y < 0 || item.y > height) continue;
ctx.strokeStyle = item.color;
ctx.fillStyle = item.color;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(right - 22, item.y + 0.5);
ctx.lineTo(right, item.y + 0.5);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillText(`${item.key} ${Number(item.value).toFixed(2)}`, right - 26, item.y);
}
ctx.restore();
});
}
}
class ConfluenceChart { class ConfluenceChart {
constructor() { constructor() {
this.chart = null; this.chart = null;
@ -274,7 +355,7 @@ class ConfluenceChart {
this.commentNodes = new Map(); this.commentNodes = new Map();
this.commentLayer = null; this.commentLayer = null;
this.contextLabelLayer = null; this.contextLabelLayer = null;
this.sessionRangeLayer = null; this.sessionRangePrimitive = null;
this.sessionRangeEnabled = true; this.sessionRangeEnabled = true;
this.sessionT = null; this.sessionT = null;
this.sessionHigh = null; this.sessionHigh = null;
@ -462,6 +543,8 @@ class ConfluenceChart {
this.candles.attachPrimitive(this.rthPrimitive); this.candles.attachPrimitive(this.rthPrimitive);
this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time)); this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time));
this.candles.attachPrimitive(this.fibPrimitive); this.candles.attachPrimitive(this.fibPrimitive);
this.sessionRangePrimitive = new SessionRangePrimitive();
this.candles.attachPrimitive(this.sessionRangePrimitive);
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, { this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
priceScaleId: 'right', priceScaleId: 'right',
color: 'rgba(0,0,0,0)', color: 'rgba(0,0,0,0)',
@ -490,9 +573,6 @@ class ConfluenceChart {
this.contextLabelLayer = document.createElement('div'); this.contextLabelLayer = document.createElement('div');
this.contextLabelLayer.className = 'chart-context-labels'; this.contextLabelLayer.className = 'chart-context-labels';
this.overlayLayer.appendChild(this.contextLabelLayer); this.overlayLayer.appendChild(this.contextLabelLayer);
this.sessionRangeLayer = document.createElement('div');
this.sessionRangeLayer.className = 'session-range-marks';
this.overlayLayer.appendChild(this.sessionRangeLayer);
this.tooltip = document.createElement('div'); this.tooltip = document.createElement('div');
this.tooltip.className = 'chart-tooltip'; this.tooltip.className = 'chart-tooltip';
this.overlayLayer.appendChild(this.tooltip); this.overlayLayer.appendChild(this.tooltip);
@ -864,7 +944,7 @@ class ConfluenceChart {
setSessionRangeEnabled(enabled) { setSessionRangeEnabled(enabled) {
this.sessionRangeEnabled = Boolean(enabled); this.sessionRangeEnabled = Boolean(enabled);
this.renderSessionRangeMarks(); this.pushSessionRange();
} }
setSessionRange(range) { setSessionRange(range) {
@ -878,7 +958,7 @@ class ConfluenceChart {
this.sessionHigh = Math.max(this.sessionHigh ?? range.high, range.high); this.sessionHigh = Math.max(this.sessionHigh ?? range.high, range.high);
this.sessionLow = Math.min(this.sessionLow ?? range.low, range.low); this.sessionLow = Math.min(this.sessionLow ?? range.low, range.low);
} }
this.syncSessionRangeMarks(); this.pushSessionRange();
} }
expandSessionRange(bar) { expandSessionRange(bar) {
@ -893,61 +973,15 @@ class ConfluenceChart {
this.sessionLow = bar.l; this.sessionLow = bar.l;
changed = true; changed = true;
} }
if (changed) this.syncSessionRangeMarks(); if (changed) this.pushSessionRange();
} }
renderSessionRangeMarks() { pushSessionRange() {
if (!this.sessionRangeLayer) return; this.sessionRangePrimitive?.setRange(
const show = this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d' this.sessionHigh,
&& this.sessionHigh != null && this.sessionLow != null; this.sessionLow,
if (!show) { this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d',
this.sessionRangeLayer.replaceChildren(); );
return;
}
const marks = [
{ key: 'high', label: 'H', value: this.sessionHigh },
{ key: 'low', label: 'L', value: this.sessionLow },
].map(mark => {
const node = document.createElement('div');
node.className = `session-range-mark ${mark.key}`;
node.dataset.key = mark.key;
const text = document.createElement('span');
text.textContent = `${mark.label} ${Number(mark.value).toFixed(2)}`;
node.appendChild(text);
return node;
});
this.sessionRangeLayer.replaceChildren(...marks);
this.syncSessionRangeMarks();
}
syncSessionRangeMarks() {
if (!this.sessionRangeLayer || !this.candles) return;
if (!this.sessionRangeLayer.children.length) {
if (this.sessionRangeEnabled && this.sessionHigh != null) this.renderSessionRangeMarks();
return;
}
const height = this.plotCanvas()?.getBoundingClientRect().height || 0;
const place = (key, value) => {
const node = this.sessionRangeLayer.querySelector(`[data-key="${key}"]`);
if (!node) return null;
const y = value == null ? null : this.candles.priceToCoordinate(value);
const on = y != null && y >= 0 && y <= height;
node.hidden = !on;
if (on) node.style.top = `${Math.round(y)}px`;
const label = node.querySelector('span');
if (label && value != null) {
label.textContent = `${key === 'high' ? 'H' : 'L'} ${Number(value).toFixed(2)}`;
}
return on ? y : null;
};
const highY = place('high', this.sessionHigh);
const lowY = place('low', this.sessionLow);
if (highY != null && lowY != null && Math.abs(highY - lowY) < 14) {
const high = this.sessionRangeLayer.querySelector('[data-key="high"]');
const low = this.sessionRangeLayer.querySelector('[data-key="low"]');
if (high) high.style.top = `${Math.round(highY - 7)}px`;
if (low) low.style.top = `${Math.round(lowY + 7)}px`;
}
} }
extendManualLineSeries(previous, bar) { extendManualLineSeries(previous, bar) {
@ -1643,7 +1677,6 @@ class ConfluenceChart {
this.renderProjectionDiagnostic(); this.renderProjectionDiagnostic();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
this.renderSessionRangeMarks();
this.syncCurrentPricePulse(); this.syncCurrentPricePulse();
}); });
} }

View file

@ -118,6 +118,5 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; } .chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; } .chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); } .chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }
.session-range-marks { position:absolute; inset:0; z-index:5; overflow:visible; pointer-events:none; }.session-range-mark { position:absolute; right:0; transform:translateY(-50%); display:flex; align-items:center; gap:5px; }.session-range-mark span { padding:1px 5px; border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); font-size:9px; line-height:1.25; white-space:nowrap; }.session-range-mark::after { content:''; width:22px; border-top:1px dashed currentColor; }.session-range-mark.high { color:var(--green); }.session-range-mark.low { color:var(--red); }.session-range-mark[hidden] { display:none; }
.event-number { margin-right:7px; padding:1px 5px; border-radius:3px; background:color-mix(in srgb,var(--accent) 22%,transparent); color:var(--fg); font-weight:600; } .event-number { margin-right:7px; padding:1px 5px; border-radius:3px; background:color-mix(in srgb,var(--accent) 22%,transparent); color:var(--fg); font-weight:600; }
.cluster-row { display:grid; grid-template-columns:auto auto auto minmax(0,1fr); gap:8px; align-items:baseline; padding:3px 7px; border-left:3px solid var(--green); font-size:10px; white-space:nowrap; }.cluster-row.resistance { border-left-color:var(--red); }.cluster-row + .cluster-row { border-top:1px solid var(--line); }.cluster-band { font-size:11px; color:var(--fg); }.cluster-score { font-weight:600; color:var(--accent); }.cluster-dist { color:var(--muted); }.cluster-members { color:var(--muted); overflow:hidden; text-overflow:ellipsis; } .cluster-row { display:grid; grid-template-columns:auto auto auto minmax(0,1fr); gap:8px; align-items:baseline; padding:3px 7px; border-left:3px solid var(--green); font-size:10px; white-space:nowrap; }.cluster-row.resistance { border-left-color:var(--red); }.cluster-row + .cluster-row { border-top:1px solid var(--line); }.cluster-band { font-size:11px; color:var(--fg); }.cluster-score { font-weight:600; color:var(--accent); }.cluster-dist { color:var(--muted); }.cluster-members { color:var(--muted); overflow:hidden; text-overflow:ellipsis; }