Fix trendline anchors jumping away from where they were drawn

Two faults, both of which made a finished line disagree with its own preview.

A placed anchor was being discarded by a twitch. finishToolGesture recomputed
`dragged` from the second click's own pointerdown/up, so a few pixels of
movement while pressing was read as a fresh press-drag-release: the anchor from
the first click was thrown away and the line began at the second click instead,
to the right of where it was meant to start. The preview had been rubber-banding
from the real anchor the whole time, which is why the result jumped on commit. A
pending anchor now wins over the current click's drag.

Snapping ignored where you pointed. snapPoint took the nearer of the bar's high
and low but only within 8px, so a cursor between the two snapped to neither and
returned a raw mid-bar price — and the side quietly fell back to the dropdown.
The gate is gone: with snapping on, an anchor always lands on the nearer extreme
of the nearest bar, and that choice *is* the side, a high being resistance and a
low support. app.js already preferred snappedSide over the dropdown, so the
inference was written and simply never fired.

snapPoint also guards against being handed an already-snapped point, which
carries no cursor y and previously compared against NaN.

Verified by driving the gesture in a browser: click, move, then a second click
with 4px of movement while pressed now yields anchor_t equal to the first
click's time and anchor_p exactly equal to the bar's high.

Also lands the groundwork for chart comments, inert until the UI is wired: a
`kind` on ManualLine with `pinned`, `x`, `y` and `collapsed`, a POST /comments
endpoint, GET /drawings, and the chart's DOM comment layer. Comments are stored
with the lines so they share numbering, filtering and deletion, and
ManualLineStore.levels() excludes them — a comment reaching the level list would
join a confluence cluster and push a notification about a piece of text. Tests
cover the exclusion, the derived kind for lines saved before comments existed,
and that numbering is shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 15:18:40 -05:00
parent 0465519348
commit 078bc8f42c
4 changed files with 274 additions and 8 deletions

View file

@ -25,6 +25,29 @@ class ManualLine:
number: int = 0 number: int = 0
cutoff_t: int | None = None cutoff_t: int | None = None
armed: bool = True armed: bool = True
# "" means a line predating drawing kinds; `kind` derives it. Comments carry
# their text in `note`, like every other drawing carries its label.
kind: str = ""
pinned: bool = True
x: float = 0.72
y: float = 0.12
collapsed: bool = False
@property
def drawing_kind(self) -> str:
"""What this drawing is, for grouping and filtering.
Derived when absent so drawings saved before comments existed keep
working: a zero slope was always a typed price level, anything else a
drawn trendline.
"""
if self.kind:
return self.kind
return "level" if self.horizontal else "trendline"
@property
def is_comment(self) -> bool:
return self.drawing_kind == "comment"
@property @property
def horizontal(self) -> bool: def horizontal(self) -> bool:
@ -85,6 +108,11 @@ class ManualLine:
number=int(value.get("number", 0)), number=int(value.get("number", 0)),
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None, cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
armed=bool(value.get("armed", True)), armed=bool(value.get("armed", True)),
kind=str(value.get("kind", "")),
pinned=bool(value.get("pinned", True)),
x=float(value.get("x", 0.72)),
y=float(value.get("y", 0.12)),
collapsed=bool(value.get("collapsed", False)),
) )
@ -146,4 +174,15 @@ class ManualLineStore:
self.save() self.save()
def levels(self) -> list[Level]: def levels(self) -> list[Level]:
return [line.to_level() for line in self.lines.values()] """Only the drawings that are levels.
Comments are stored alongside lines so they share numbering, filtering
and deletion, but they are annotations. Letting one through here would
put it in a confluence cluster and fire a push notification about a
piece of text.
"""
return [line.to_level() for line in self.lines.values() if not line.is_comment]
def drawings(self) -> list[ManualLine]:
"""Everything drawn, comments included, newest number last."""
return sorted(self.lines.values(), key=lambda line: line.number)

View file

@ -54,6 +54,10 @@ class LinePatch(BaseModel):
last_t: int | None = None last_t: int | None = None
cutoff_t: int | None = None cutoff_t: int | None = None
armed: bool | None = None armed: bool | None = None
pinned: bool | None = None
x: float | None = Field(None, ge=0.0, le=1.0)
y: float | None = Field(None, ge=0.0, le=1.0)
collapsed: bool | None = None
@router.get("/status") @router.get("/status")
@ -151,6 +155,58 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
return line.to_level().to_dict() return line.to_level().to_dict()
class CommentCreate(BaseModel):
text: str = Field(min_length=1, max_length=2000)
# Pinned to a moment on the chart, or floating and always on screen.
pinned: bool = True
anchor_t: int | None = None
anchor_p: float | None = None
x: float = Field(0.72, ge=0.0, le=1.0)
y: float = Field(0.12, ge=0.0, le=1.0)
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
tf: Timeframe = Timeframe.M1
@router.post("/comments", status_code=201)
def create_comment(request: Request, payload: CommentCreate):
"""A note on the chart. Stored with the lines so it shares their numbering,
filtering and deletion, but it is never a level — see ManualLineStore.levels.
"""
runtime = request.app.state.runtime
now = int(time.time())
anchored = payload.anchor_t if payload.anchor_t is not None else now
line = ManualLine(
id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf,
# Side is meaningless for a comment; clustering never sees it.
side=Side.SUPPORT,
anchor_t=anchored,
anchor_p=payload.anchor_p if payload.anchor_p is not None else (runtime.price or 0.0),
slope=0.0,
last_t=anchored,
created_at=now,
note=payload.text,
color=payload.color,
kind="comment",
pinned=payload.pinned,
x=payload.x,
y=payload.y,
# A comment must never alert, whatever else changes around it.
armed=False,
)
line = runtime.manual_lines.add(line)
return line.to_dict()
@router.get("/drawings")
def drawings(request: Request):
"""Every drawing, comments included, for the sidebar list."""
store = request.app.state.runtime.manual_lines
return {"drawings": [
{**line.to_dict(), "kind": line.drawing_kind} for line in store.drawings()
]}
@router.patch("/lines/{line_id}") @router.patch("/lines/{line_id}")
def patch_line(request: Request, line_id: str, payload: LinePatch): def patch_line(request: Request, line_id: str, payload: LinePatch):
changes = payload.model_dump(exclude_none=True) changes = payload.model_dump(exclude_none=True)

View file

@ -33,6 +33,11 @@ class ConfluenceChart {
this.toolMoveListener = null; this.toolMoveListener = null;
this.toolUpListener = null; this.toolUpListener = null;
this.pendingView = null; this.pendingView = null;
this.comments = [];
this.commentNodes = new Map();
this.commentLayer = null;
this.onCommentToggle = null;
this.onCommentMove = null;
} }
static TICK = 0.25; static TICK = 0.25;
@ -161,6 +166,9 @@ 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.commentLayer = document.createElement('div');
this.commentLayer.className = 'chart-comments';
el.appendChild(this.commentLayer);
this.toolDownListener = event => this.startToolGesture(event); this.toolDownListener = event => this.startToolGesture(event);
this.toolMoveListener = event => this.moveToolGesture(event); this.toolMoveListener = event => this.moveToolGesture(event);
this.toolUpListener = event => this.finishToolGesture(event); this.toolUpListener = event => this.finishToolGesture(event);
@ -192,7 +200,10 @@ class ConfluenceChart {
} }
this.updateLineTooltip(param); this.updateLineTooltip(param);
}); });
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles();
this.renderComments();
});
} }
setBars(bars) { setBars(bars) {
@ -235,6 +246,99 @@ class ConfluenceChart {
this.renderAnchorHandles(); this.renderAnchorHandles();
} }
setComments(comments) {
this.comments = comments;
this.renderComments();
}
setCommentHandlers({ onToggle, onMove }) {
this.onCommentToggle = onToggle;
this.onCommentMove = onMove;
}
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
// and a floating one must ignore the time scale entirely.
renderComments() {
if (!this.commentLayer) return;
const width = this.chartEl.clientWidth;
const height = this.chartEl.clientHeight;
const seen = new Set();
for (const comment of this.comments || []) {
seen.add(comment.id);
let node = this.commentNodes.get(comment.id);
if (!node) {
node = this.buildCommentNode(comment);
this.commentNodes.set(comment.id, node);
this.commentLayer.appendChild(node.root);
}
node.comment = comment;
node.root.classList.toggle('collapsed', !!comment.collapsed);
node.root.classList.toggle('floating', !comment.pinned);
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
node.root.style.borderColor = comment.color;
node.root.title = comment.note;
if (!comment.pinned) {
// A fraction of the pane, so it holds its place through any zoom.
node.root.style.left = `${Math.round(comment.x * width)}px`;
node.root.style.top = `${Math.round(comment.y * height)}px`;
node.root.classList.remove('off-left', 'off-right');
continue;
}
const x = this.chart.timeScale().timeToCoordinate(comment.anchor_t);
const y = this.candles.priceToCoordinate(comment.anchor_p);
// Scrolled past: park it on the edge it went off, pointing that way,
// rather than letting it vanish and be hard to find again.
const offLeft = x === null || x < 0;
const offRight = x !== null && x > width;
node.root.classList.toggle('off-left', offLeft && !offRight);
node.root.classList.toggle('off-right', offRight);
const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x;
node.root.style.left = `${Math.round(clampedX)}px`;
node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
}
for (const [id, node] of this.commentNodes) {
if (!seen.has(id)) {
node.root.remove();
this.commentNodes.delete(id);
}
}
}
buildCommentNode(comment) {
const root = document.createElement('div');
root.className = 'chart-comment';
const label = document.createElement('span');
label.className = 'chart-comment-text';
root.appendChild(label);
const node = { root, label, comment };
root.addEventListener('click', event => {
event.stopPropagation();
if (this.onCommentToggle) this.onCommentToggle(node.comment);
});
// Floating comments are dragged; pinned ones belong to their bar.
root.addEventListener('pointerdown', event => {
if (node.comment.pinned) return;
event.stopPropagation();
const bounds = this.chartEl.getBoundingClientRect();
const move = moveEvent => {
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - 8)}px`;
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - 8)}px`;
};
const up = upEvent => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
const x = (upEvent.clientX - bounds.left - 8) / bounds.width;
const y = (upEvent.clientY - bounds.top - 8) / bounds.height;
const clamp = value => Math.min(Math.max(value, 0), 1);
if (this.onCommentMove) this.onCommentMove(node.comment, clamp(x), clamp(y));
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
});
return node;
}
// A typed price alert is a manual line with zero slope. Treating it as flat // A typed price alert is a manual line with zero slope. Treating it as flat
// here is what makes it render as a level rather than a stubby segment. // here is what makes it render as a level rather than a stubby segment.
static isFlat(level) { static isFlat(level) {
@ -453,8 +557,15 @@ 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.
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5; // A placed anchor wins over this click's own drag. Recomputing `dragged`
const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end)); // 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);
const b = this.snapPoint(end); const b = this.snapPoint(end);
if (!dragged && !this.pendingAnchor) { if (!dragged && !this.pendingAnchor) {
this.pendingAnchor = b; this.pendingAnchor = b;
@ -467,11 +578,23 @@ class ConfluenceChart {
this.onToolComplete?.({ tool, start: a, end: b }); this.onToolComplete?.({ tool, start: a, end: b });
} }
/** Trendline anchors snap to bar extremes; levels snap to the tick grid. */ /**
* Trendline anchors snap to bar extremes; levels snap to the tick grid.
*
* With snapping on, an anchor always lands on the nearer extreme of the
* nearest bar — there is no proximity gate. An 8px one used to let the
* cursor fall between the high and the low and snap to neither, so the
* preview showed a raw mid-bar price and the side quietly fell back to the
* dropdown. Which extreme you land on *is* the side: a high is resistance, a
* low is support, so the choice is made by where you point rather than by a
* control you have to remember to set. An already-snapped point carries no
* cursor y, so it is returned untouched rather than re-snapped against NaN.
*/
snapPoint(point) { snapPoint(point) {
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null; const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
const base = { t: fallbackT, p: point.p, snappedSide: null }; const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
if (point.y == null) return base;
const nearest = this.bars.reduce( const nearest = this.bars.reduce(
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best), (best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
); );
@ -482,8 +605,7 @@ class ConfluenceChart {
const snapped = candidates const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) })) .map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
.sort((a, b) => a.distance - b.distance)[0]; .sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return base;
} }
renderGesture() { renderGesture() {

View file

@ -40,3 +40,52 @@ def test_manual_line_raises_existing_ma_cluster_score():
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0] after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
assert before.score == 12 assert before.score == 12
assert after.score == 16 assert after.score == 16
def test_a_comment_is_never_a_level(tmp_path):
# Comments live with the lines so they share numbering, filtering and
# deletion — but a comment reaching levels() would join a confluence
# cluster and fire a push notification about a piece of text.
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
store.add(ManualLine(id="ml_level", slope=0.0, **common))
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
note="watch this", **common))
assert [level.id for level in store.levels()] == ["ml_level"]
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note"]
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
flat = store.add(ManualLine(id="ml_flat", slope=0.0, **common))
sloped = store.add(ManualLine(id="ml_sloped", slope=0.5, **common))
assert flat.drawing_kind == "level"
assert sloped.drawing_kind == "trendline"
def test_every_drawing_gets_a_number_including_comments(tmp_path):
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.levels import Side
from app.bars.models import Timeframe
store = ManualLineStore(tmp_path / "lines.json")
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
anchor_t=1000, last_t=2000, created_at=1000)
first = store.add(ManualLine(id="ml_a", slope=0.0, **common))
note = store.add(ManualLine(id="ml_b", slope=0.0, kind="comment", **common))
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
assert [first.number, note.number, third.number] == [1, 2, 3]