Make trendline placement match what the cursor shows

Two faults, one of them a regression that reached production.

Making a pending anchor always win fixed a twitch on the second click stealing
the start point, and broke the opposite case: a genuine press-drag begun after
an abandoned click was hijacked by that stale anchor, so the line started far
from where the drag did. A single threshold decides now — 12px of travel between
press and release makes a gesture a drag, wide enough to survive a twitch on a
deliberate click and unambiguous for a real one. A drag abandons any half-placed
anchor instead of adopting it.

The crosshair was also lying. Lightweight Charts defaults to CrosshairMode.Magnet,
which snaps the crosshair to the bar's close, so hovering beside a bar's low drew
it mid-bar and a correctly-placed anchor looked wrong. Measured: aiming 4px above
a bar low anchors at the low, 7773, not the close, 7773.25 — the placement was
right and only the feedback was wrong. Arming a tool switches the crosshair to
Normal, and a snap dot now marks the exact point the anchor will use, coloured by
the side that extreme implies.

Verified in a browser across all four paths: two clicks with a twitch on the
second, an abandoned click then a real drag, a plain press-drag, and hovering.
Each starts where it should and lands on a bar extreme, and the stale anchor is
no longer adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 16:29:00 -05:00
parent 9008e9cd8e
commit 0b3244b725
3 changed files with 77 additions and 10 deletions

View file

@ -1499,3 +1499,30 @@ snap on shows the note and no dropdown, snap off shows the dropdown.
`created_at` (epoch seconds) is already stored on every drawing and returned by `created_at` (epoch seconds) is already stored on every drawing and returned by
`GET /api/drawings`, so filtering by age needs UI only, not a migration. `GET /api/drawings`, so filtering by age needs UI only, not a migration.
**Trendline placement, third pass — and a regression I shipped.** Making a
pending anchor always win (previous entry) fixed the twitch case and broke the
opposite one: a genuine press-drag begun after an abandoned click was hijacked
by that stale anchor, so the line started far from the drag. That reached
production. The rule is now a single threshold — 12px of travel between press
and release makes it a drag, which is wide enough to survive a twitch on a
deliberate click and unambiguous for a real drag. A drag clears any half-placed
anchor rather than silently adopting it.
**The crosshair was lying about the anchor.** Lightweight Charts defaults to
`CrosshairMode.Magnet`, which snaps the crosshair to the bar's *close*. Hovering
by a bar's low therefore drew the crosshair mid-bar, and a correctly-snapped
anchor looked wrong — measured: aiming 4px above a bar low placed the anchor at
the low (7773) and not the close (7773.25), while the crosshair sat at the
close. Arming a tool now switches the crosshair to `Normal`, and a snap dot
marks the exact point the anchor will use, coloured by the side it implies.
Four gesture paths are verified in a browser: two clicks with a twitch on the
second, an abandoned click followed by a real drag, a plain press-drag, and
hovering. All start where they should and land on a bar extreme.
Worth recording for diagnosis: a reported "line ended up high off the bar"
turned out to render exactly on its bar — zero pixels off at 1h, 30m and 15m —
because the anchor had snapped to the *drawn* timeframe extreme (the 09:00 1h
low, 7744.25) while being checked against 1m bars, where it matches neither
extreme. Always compare an anchor against the timeframe it was drawn on.

View file

@ -33,6 +33,7 @@ class ConfluenceChart {
this.toolMoveListener = null; this.toolMoveListener = null;
this.toolUpListener = null; this.toolUpListener = null;
this.pendingView = null; this.pendingView = null;
this.snapDot = null;
this.comments = []; this.comments = [];
this.commentNodes = new Map(); this.commentNodes = new Map();
this.commentLayer = null; this.commentLayer = null;
@ -42,6 +43,10 @@ class ConfluenceChart {
static TICK = 0.25; 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;
static snapToTick(price) { static snapToTick(price) {
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK; return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
} }
@ -166,6 +171,10 @@ class ConfluenceChart {
this.priceTag.className = 'chart-price-tag'; this.priceTag.className = 'chart-price-tag';
this.priceTag.hidden = true; this.priceTag.hidden = true;
el.appendChild(this.priceTag); el.appendChild(this.priceTag);
this.snapDot = document.createElement('div');
this.snapDot.className = 'chart-snap-dot';
this.snapDot.hidden = true;
el.appendChild(this.snapDot);
this.commentLayer = document.createElement('div'); this.commentLayer = document.createElement('div');
this.commentLayer.className = 'chart-comments'; this.commentLayer.className = 'chart-comments';
el.appendChild(this.commentLayer); el.appendChild(this.commentLayer);
@ -527,7 +536,18 @@ class ConfluenceChart {
*/ */
armTool(tool) { armTool(tool) {
this.armedTool = tool; this.armedTool = tool;
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool }); // Magnet snaps the crosshair to the bar's close, so hovering by a bar's low
// drew it mid-bar and made a correctly-placed anchor look wrong. While a
// tool is armed the crosshair tracks the cursor and the snap dot shows
// where the anchor will actually land.
this.chart.applyOptions({
handleScroll: !tool,
handleScale: !tool,
crosshair: {
mode: tool ? LightweightCharts.CrosshairMode.Normal : LightweightCharts.CrosshairMode.Magnet,
},
});
this.hideSnapDot();
this.chartEl.classList.toggle('armed', Boolean(tool)); this.chartEl.classList.toggle('armed', Boolean(tool));
this.pendingAnchor = null; this.pendingAnchor = null;
this.clearGesture(); this.clearGesture();
@ -558,6 +578,7 @@ class ConfluenceChart {
if (!this.armedTool) return; if (!this.armedTool) return;
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!point) return; if (!point) return;
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point));
if (this.gesture) { if (this.gesture) {
this.gesture.end = point; this.gesture.end = point;
this.renderGesture(); this.renderGesture();
@ -567,6 +588,22 @@ class ConfluenceChart {
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point)); if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
} }
showSnapDot(snapped) {
if (snapped.t == null) return this.hideSnapDot();
const x = this.chart.timeScale().timeToCoordinate(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 || '';
}
hideSnapDot() {
if (this.snapDot) this.snapDot.hidden = true;
}
renderPending(a, b) { renderPending(a, b) {
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
} }
@ -605,21 +642,23 @@ class ConfluenceChart {
// it. Rejecting short gestures outright left the tool armed and silent, so // 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 // the next click began a whole new line — which read as the line suddenly
// continuing at a different slope. // continuing at a different slope.
// A placed anchor wins over this click's own drag. Recomputing `dragged` // Deliberate drag or click? At 5px a twitch while pressing the second click
// here meant a few pixels of movement while pressing the second click was // counted as a drag, which threw away the anchor placed by the first click
// read as a fresh press-drag-release, which threw the anchor away and // and restarted the line under the cursor. Making a pending anchor always
// started the line at the second click instead — the preview had been // win fixed that and broke the other direction: a real press-drag begun
// rubber-banding from the real anchor all along, so the finished line // after an abandoned click was hijacked by that stale anchor, starting the
// jumped away from where it had just been drawn. // line far from where the drag did. So the threshold decides, and it is
const dragged = !this.pendingAnchor // wide enough that only a deliberate drag clears the anchor.
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5; const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD;
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
const b = this.snapPoint(end); const b = this.snapPoint(end);
if (!dragged && !this.pendingAnchor) { if (!dragged && !this.pendingAnchor) {
this.pendingAnchor = b; this.pendingAnchor = b;
this.renderPending(b, b); this.renderPending(b, b);
return; 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.pendingAnchor = null;
this.clearLinePreview(); this.clearLinePreview();
if (a.t == null || b.t == null || a.t === b.t) return; if (a.t == null || b.t == null || a.t === b.t) return;

View file

@ -77,3 +77,4 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical; .tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); } border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; } .side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; }