From 72bcd0fa61ad5389aaa7dd9419dc5029c2015853 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 1 Sep 2026 22:47:16 -0500 Subject: [PATCH] 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. --- static/chart.js | 153 ++++++++++++++++++++++++++++------------------- static/style.css | 1 - 2 files changed, 93 insertions(+), 61 deletions(-) diff --git a/static/chart.js b/static/chart.js index 17f1cda..002f4ef 100644 --- a/static/chart.js +++ b/static/chart.js @@ -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 { constructor() { this.chart = null; @@ -274,7 +355,7 @@ class ConfluenceChart { this.commentNodes = new Map(); this.commentLayer = null; this.contextLabelLayer = null; - this.sessionRangeLayer = null; + this.sessionRangePrimitive = null; this.sessionRangeEnabled = true; this.sessionT = null; this.sessionHigh = null; @@ -462,6 +543,8 @@ class ConfluenceChart { this.candles.attachPrimitive(this.rthPrimitive); this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time)); this.candles.attachPrimitive(this.fibPrimitive); + this.sessionRangePrimitive = new SessionRangePrimitive(); + this.candles.attachPrimitive(this.sessionRangePrimitive); this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, { priceScaleId: 'right', color: 'rgba(0,0,0,0)', @@ -490,9 +573,6 @@ class ConfluenceChart { this.contextLabelLayer = document.createElement('div'); this.contextLabelLayer.className = 'chart-context-labels'; 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.className = 'chart-tooltip'; this.overlayLayer.appendChild(this.tooltip); @@ -864,7 +944,7 @@ class ConfluenceChart { setSessionRangeEnabled(enabled) { this.sessionRangeEnabled = Boolean(enabled); - this.renderSessionRangeMarks(); + this.pushSessionRange(); } setSessionRange(range) { @@ -878,7 +958,7 @@ class ConfluenceChart { this.sessionHigh = Math.max(this.sessionHigh ?? range.high, range.high); this.sessionLow = Math.min(this.sessionLow ?? range.low, range.low); } - this.syncSessionRangeMarks(); + this.pushSessionRange(); } expandSessionRange(bar) { @@ -893,61 +973,15 @@ class ConfluenceChart { this.sessionLow = bar.l; changed = true; } - if (changed) this.syncSessionRangeMarks(); + if (changed) this.pushSessionRange(); } - renderSessionRangeMarks() { - if (!this.sessionRangeLayer) return; - const show = this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d' - && this.sessionHigh != null && this.sessionLow != null; - if (!show) { - 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`; - } + pushSessionRange() { + this.sessionRangePrimitive?.setRange( + this.sessionHigh, + this.sessionLow, + this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d', + ); } extendManualLineSeries(previous, bar) { @@ -1643,7 +1677,6 @@ class ConfluenceChart { this.renderProjectionDiagnostic(); this.renderComments(); this.renderContextLabels(); - this.renderSessionRangeMarks(); this.syncCurrentPricePulse(); }); } diff --git a/static/style.css b/static/style.css index 5f3e84f..670e3c3 100644 --- a/static/style.css +++ b/static/style.css @@ -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-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); } -.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; } .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; }