diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 0421fd6..63305dc 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. diff --git a/static/chart.js b/static/chart.js index 4963b23..e7fdc49 100644 --- a/static/chart.js +++ b/static/chart.js @@ -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; diff --git a/static/style.css b/static/style.css index e25902e..85755ea 100644 --- a/static/style.css +++ b/static/style.css @@ -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; }