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:
parent
9008e9cd8e
commit
0b3244b725
3 changed files with 77 additions and 10 deletions
|
|
@ -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
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class ConfluenceChart {
|
|||
this.toolMoveListener = null;
|
||||
this.toolUpListener = null;
|
||||
this.pendingView = null;
|
||||
this.snapDot = null;
|
||||
this.comments = [];
|
||||
this.commentNodes = new Map();
|
||||
this.commentLayer = null;
|
||||
|
|
@ -42,6 +43,10 @@ class ConfluenceChart {
|
|||
|
||||
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) {
|
||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||
}
|
||||
|
|
@ -166,6 +171,10 @@ class ConfluenceChart {
|
|||
this.priceTag.className = 'chart-price-tag';
|
||||
this.priceTag.hidden = true;
|
||||
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.className = 'chart-comments';
|
||||
el.appendChild(this.commentLayer);
|
||||
|
|
@ -527,7 +536,18 @@ class ConfluenceChart {
|
|||
*/
|
||||
armTool(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.pendingAnchor = null;
|
||||
this.clearGesture();
|
||||
|
|
@ -558,6 +578,7 @@ class ConfluenceChart {
|
|||
if (!this.armedTool) return;
|
||||
const point = this.eventPoint(event);
|
||||
if (!point) return;
|
||||
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point));
|
||||
if (this.gesture) {
|
||||
this.gesture.end = point;
|
||||
this.renderGesture();
|
||||
|
|
@ -567,6 +588,22 @@ class ConfluenceChart {
|
|||
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) {
|
||||
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
|
||||
// the next click began a whole new line — which read as the line suddenly
|
||||
// continuing at a different slope.
|
||||
// A placed anchor wins over this click's own drag. Recomputing `dragged`
|
||||
// here meant a few pixels of movement while pressing the second click was
|
||||
// read as a fresh press-drag-release, which threw the anchor away and
|
||||
// started the line at the second click instead — the preview had been
|
||||
// rubber-banding from the real anchor all along, so the finished line
|
||||
// jumped away from where it had just been drawn.
|
||||
const dragged = !this.pendingAnchor
|
||||
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
||||
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
|
||||
// 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 || a.t === b.t) return;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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; }
|
||||
.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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue