Keep a pinned comment in place across a timeframe switch

A comment placed on a 30m bar slid to the far left the moment the chart switched
to 15m. timeToCoordinate answers only for times that are data points on the
current series, so a 30m bucket start returns null on another timeframe — and
the render was reading null as "off the left edge", which parked every such
comment against the left of the pane.

Anchors resolve to the bar that contains them instead, found by binary search
over the current bars, which is timeframe-independent: an 09:30 note sits on the
09:30 bar at 15m and on the 09:00 bar at 1h. Times genuinely before the first
bar or after the last are reported separately, so real off-screen comments still
park on the edge they left rather than being confused with unresolved ones.
setBars re-renders comments too, since a timeframe switch replaces the bar grid
underneath every pinned one.

Verified in a browser across 30m to 15m to 1h and back: the anchor stays 08:30
throughout, resolving to the 08:30 bar at 15m and the 08:00 bar at 1h, never
edge-parked, and returning to its original coordinate. Edge-parking still fires
where it should — scrolled 400 bars away a comment parks right, and comes back
when the view returns to live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 16:10:51 -05:00
parent 9ab68f6cff
commit 9b6cff853b
2 changed files with 51 additions and 3 deletions

View file

@ -1474,3 +1474,18 @@ deleting by type or by string a single button.
One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured
the comment row's icon span and dragged it into the text column. Scoped to the comment row's icon span and dragged it into the text column. Scoped to
`span:not(.drawing-icon)`. `span:not(.drawing-icon)`.
**A comment lost its place when the timeframe changed.** Placed on a 30m bar,
then switched to 15m, it slid to the far left. `timeToCoordinate` answers only
for times that are data points on the current series, so a 30m bucket start
returned `null` on another timeframe — and `null` was being read as "off the
left edge". Anchors are now resolved to the bar that *contains* them, which is
timeframe-independent: an 09:30 note sits on the 09:30 bar at 15m and on the
09:00 bar at 1h. `setBars` also re-renders comments, since a timeframe switch
replaces the grid underneath every pinned one.
Verified across 30m → 15m → 1h → 30m: the anchor stays 08:30 throughout,
resolving to the 08:30 bar on 15m and the 08:00 bar on 1h, never edge-parked,
and returning to its original x on the way back. Edge-parking still works where
it should — a comment scrolled 400 bars out parks right and comes back on
return to live.

View file

@ -229,6 +229,8 @@ class ConfluenceChart {
this.chart.timeScale().setVisibleRange(this.pendingView); this.chart.timeScale().setVisibleRange(this.pendingView);
} }
requestAnimationFrame(() => this.renderAnchorHandles()); requestAnimationFrame(() => this.renderAnchorHandles());
// The bar grid just changed underneath every pinned comment.
this.renderComments();
} }
updateBar(bar) { updateBar(bar) {
@ -285,12 +287,13 @@ class ConfluenceChart {
node.root.classList.remove('off-left', 'off-right'); node.root.classList.remove('off-left', 'off-right');
continue; continue;
} }
const x = this.chart.timeScale().timeToCoordinate(comment.anchor_t); const placed = this.anchorCoordinate(comment.anchor_t);
const x = placed.x;
const y = this.candles.priceToCoordinate(comment.anchor_p); const y = this.candles.priceToCoordinate(comment.anchor_p);
// Scrolled past: park it on the edge it went off, pointing that way, // Scrolled past: park it on the edge it went off, pointing that way,
// rather than letting it vanish and be hard to find again. // rather than letting it vanish and be hard to find again.
const offLeft = x === null || x < 0; const offLeft = placed.before || (x !== null && x < 0);
const offRight = x !== null && x > width; const offRight = placed.after || (x !== null && x > width);
node.root.classList.toggle('off-left', offLeft && !offRight); node.root.classList.toggle('off-left', offLeft && !offRight);
node.root.classList.toggle('off-right', offRight); node.root.classList.toggle('off-right', offRight);
const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x; const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x;
@ -305,6 +308,36 @@ class ConfluenceChart {
} }
} }
/**
* 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 };
if (anchorT < this.bars[0].t) return { x: null, before: true, after: false };
const lastBar = this.bars[this.bars.length - 1];
if (anchorT > lastBar.t) {
// Past the newest bar is not off the chart — the right edge carries
// whitespace, so ask the scale directly and only park it if it answers.
const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t);
return { x: beyond, before: false, after: beyond === null };
}
let lo = 0;
let hi = this.bars.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.bars[mid].t <= anchorT) lo = mid;
else hi = mid - 1;
}
return { x: this.chart.timeScale().timeToCoordinate(this.bars[lo].t), before: false, after: false };
}
buildCommentNode(comment) { buildCommentNode(comment) {
const root = document.createElement('div'); const root = document.createElement('div');
root.className = 'chart-comment'; root.className = 'chart-comment';