diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index ca3cd03..acce005 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -25,6 +25,29 @@ class ManualLine: number: int = 0 cutoff_t: int | None = None 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 def horizontal(self) -> bool: @@ -85,6 +108,11 @@ class ManualLine: number=int(value.get("number", 0)), cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None, 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() 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) diff --git a/app/api/routes.py b/app/api/routes.py index 7100c2b..4c4a3fc 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -54,6 +54,10 @@ class LinePatch(BaseModel): last_t: int | None = None cutoff_t: int | 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") @@ -151,6 +155,58 @@ def create_price_alert(request: Request, payload: PriceAlertCreate): 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}") def patch_line(request: Request, line_id: str, payload: LinePatch): changes = payload.model_dump(exclude_none=True) diff --git a/static/chart.js b/static/chart.js index 8f7d006..db3a4f2 100644 --- a/static/chart.js +++ b/static/chart.js @@ -33,6 +33,11 @@ class ConfluenceChart { this.toolMoveListener = null; this.toolUpListener = null; this.pendingView = null; + this.comments = []; + this.commentNodes = new Map(); + this.commentLayer = null; + this.onCommentToggle = null; + this.onCommentMove = null; } static TICK = 0.25; @@ -161,6 +166,9 @@ class ConfluenceChart { this.priceTag.className = 'chart-price-tag'; this.priceTag.hidden = true; 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.toolMoveListener = event => this.moveToolGesture(event); this.toolUpListener = event => this.finishToolGesture(event); @@ -192,7 +200,10 @@ class ConfluenceChart { } this.updateLineTooltip(param); }); - this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles()); + this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { + this.renderAnchorHandles(); + this.renderComments(); + }); } setBars(bars) { @@ -235,6 +246,99 @@ class ConfluenceChart { 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 // here is what makes it render as a level rather than a stubby segment. static isFlat(level) { @@ -453,8 +557,15 @@ 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. - const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5; - const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end)); + // 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); const b = this.snapPoint(end); if (!dragged && !this.pendingAnchor) { this.pendingAnchor = b; @@ -467,11 +578,23 @@ class ConfluenceChart { 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) { 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 (point.y == null) return base; const nearest = this.bars.reduce( (best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best), ); @@ -482,8 +605,7 @@ class ConfluenceChart { const snapped = candidates .map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) })) .sort((a, b) => a.distance - b.distance)[0]; - if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; - return base; + return { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; } renderGesture() { diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index 417e50b..01f6022 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -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] assert before.score == 12 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]