Compare commits

..

No commits in common. "0b3244b725afe6abdc90daf207661e7b938f7f9d" and "9b6cff853b9c48058513d2abf0f90cde48197662" have entirely different histories.

4 changed files with 11 additions and 92 deletions

View file

@ -1489,40 +1489,3 @@ 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 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 it should — a comment scrolled 400 bars out parks right and comes back on
return to live. return to live.
**The trendline Side control became inert.** Once snapping always lands on a
bar extreme, the side is inferred from *which* extreme — a high is resistance, a
low is support — so the dropdown could no longer affect anything. It now appears
only when "Snap to highs/lows" is off, which is the one case where there is no
extreme to infer from; otherwise the row reads "Side auto". Verified both ways:
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.

View file

@ -33,7 +33,6 @@ 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;
@ -43,10 +42,6 @@ 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;
} }
@ -171,10 +166,6 @@ 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);
@ -536,18 +527,7 @@ class ConfluenceChart {
*/ */
armTool(tool) { armTool(tool) {
this.armedTool = tool; this.armedTool = tool;
// Magnet snaps the crosshair to the bar's close, so hovering by a bar's low this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
// 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();
@ -578,7 +558,6 @@ 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();
@ -588,22 +567,6 @@ 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);
} }
@ -642,23 +605,21 @@ 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.
// Deliberate drag or click? At 5px a twitch while pressing the second click // A placed anchor wins over this click's own drag. Recomputing `dragged`
// counted as a drag, which threw away the anchor placed by the first click // here meant a few pixels of movement while pressing the second click was
// and restarted the line under the cursor. Making a pending anchor always // read as a fresh press-drag-release, which threw the anchor away and
// win fixed that and broke the other direction: a real press-drag begun // started the line at the second click instead — the preview had been
// after an abandoned click was hijacked by that stale anchor, starting the // rubber-banding from the real anchor all along, so the finished line
// line far from where the drag did. So the threshold decides, and it is // jumped away from where it had just been drawn.
// wide enough that only a deliberate drag clears the anchor. const dragged = !this.pendingAnchor
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= ConfluenceChart.DRAG_THRESHOLD; && Math.hypot(end.x - start.x, end.y - start.y) >= 5;
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

@ -65,10 +65,7 @@
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select></label> <label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select></label>
</div> </div>
<div class="row"> <div class="row">
<!-- Side is inferred from the extreme you snap to, so it only has <label>Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
anything to say when snapping is off. -->
<label v-if="!snap">Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
<span v-else class="side-auto" title="A high is resistance, a low is support">Side <b>auto</b></span>
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label> <label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
</div> </div>
</div> </div>

View file

@ -76,5 +76,3 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
border-radius:4px; background:transparent; color:var(--muted); cursor:pointer; } border-radius:4px; background:transparent; color:var(--muted); cursor:pointer; }
.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; }
.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; }