diff --git a/app/api/routes.py b/app/api/routes.py index 4c4a3fc..2d524ba 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -217,7 +217,9 @@ def patch_line(request: Request, line_id: str, payload: LinePatch): except KeyError as exc: raise HTTPException(404, "Line not found") from exc request.app.state.runtime.rebuild_levels() - return line.to_level().to_dict() + # A comment has no level form — returning one would hand the caller a shape + # that looks like something the confluence engine tracks. + return line.to_dict() if line.is_comment else line.to_level().to_dict() @router.delete("/lines/{line_id}", status_code=204) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index b1f50c9..98b17a5 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1444,3 +1444,33 @@ nothing selected. Five changes, no functionality removed: Measured with nothing armed: 1110px of content down to 900px, which is inside the viewport rather than past it. `.sidebar-section:first-of-type` carries the zeroed top margin so reordering cannot reintroduce a gap at the top. + +### 2026-08-10 (evening) — chart comments, and Drawings + +**Comments are drawings, not levels.** A comment is stored as a `ManualLine` +with `kind="comment"`, so it inherits persistence, the shared drawing-number +sequence, the sidebar list, filtering and deletion without a parallel set of +endpoints. The one rule that must never bend: `ManualLineStore.levels()` filters +comments out. A comment reaching the level list would join a confluence cluster +and push a phone notification about a piece of text. It is also created with +`armed=False`, and `PATCH /lines/{id}` returns `to_dict()` rather than +`to_level()` for one, so no caller is ever handed a level-shaped comment. + +`kind` is derived when absent — zero slope was always a typed level, anything +else a drawn trendline — so drawings saved before comments existed keep working. + +**Pinned or floating.** Pinned comments carry `anchor_t`/`anchor_p` and move with +the chart; floating ones carry `x`/`y` as fractions of the pane, hold their place +through any zoom, and can be dragged. Comments render as DOM rather than canvas: +they hold arbitrary text, collapse to a numbered dot, and a floating one has to +ignore the time scale entirely. A pinned comment scrolled out of view parks on +the edge it left, pointing back the way it went, so it is never simply lost. + +**"Lines & levels" becomes "Drawings"**, filtered by type and by text — the text +match covers the label, the kind and the `#number`, so `comment`, `cpi` and `7` +all narrow the list. Delete acts on what the filter shows, which is what makes +deleting by type or by string a single button. + +One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured +the comment row's icon span and dragged it into the text column. Scoped to +`span:not(.drawing-icon)`. diff --git a/static/app.js b/static/app.js index 0af95ea..fffb055 100644 --- a/static/app.js +++ b/static/app.js @@ -84,6 +84,13 @@ createApp({ const drawColor = ref('#65b7cf'); const drawWidth = ref(2); const drawSide = ref('support'); + const commentText = ref(''); + const commentFloat = ref(false); + // Comments are not levels, so they never arrive over the levels socket and + // are fetched and refreshed explicitly. + const comments = ref([]); + const drawingFilter = ref(''); + const drawingKind = ref('all'); const snap = ref(true); const selectedLine = ref(null); const selectedLines = ref([]); @@ -107,6 +114,37 @@ createApp({ const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); + // One list for everything drawn. Lines come from the levels socket, which + // keeps them live; comments come from the drawings endpoint. + const drawings = computed(() => { + const lines = manualLines.value.map(line => ({ + id: line.id, + number: line.number, + kind: line.slope === 0 ? 'level' : 'trendline', + label: line.label || '', + line, + })); + const notes = comments.value.map(comment => ({ + id: comment.id, + number: comment.number, + kind: 'comment', + label: comment.note || '', + comment, + })); + return [...lines, ...notes].sort((a, b) => a.number - b.number); + }); + + const filteredDrawings = computed(() => { + const needle = drawingFilter.value.trim().toLowerCase(); + return drawings.value.filter(item => { + if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false; + if (!needle) return true; + // Matched against what is on screen — the label or comment text, the + // kind, and the drawing number, so "comment", "cpi" and "7" all work. + return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle); + }); + }); + async function refreshStatus() { const response = await apiFetch('/api/status'); if (response.ok) status.value = await response.json(); @@ -210,6 +248,10 @@ createApp({ // One placement per arming, so a tool cannot keep firing on stray drags. armedTool.value = null; chartApi.armTool(null); + if (result.tool === 'comment') { + await createComment(result); + return; + } if (result.tool === 'level') { alertPrice.value = result.price; await addPriceAlert(); @@ -218,6 +260,64 @@ createApp({ await createTrendline(result.start, result.end); } + async function refreshComments() { + const response = await apiFetch('/api/drawings'); + if (!response.ok) return; + const payload = await response.json(); + comments.value = (payload.drawings || []).filter(item => item.kind === 'comment'); + syncComments(); + } + + function syncComments() { + if (chartApi) chartApi.setComments(comments.value.filter(item => !item.hidden)); + } + + async function createComment(result) { + const text = commentText.value.trim(); + if (!text) return; + const response = await apiFetch('/api/comments', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + pinned: !commentFloat.value, + anchor_t: result.time ?? null, + anchor_p: result.price ?? null, + x: result.x, y: result.y, + }), + }); + if (!response.ok) { + console.error('Unable to save comment', response.status); + return; + } + commentText.value = ''; + await refreshComments(); + } + + async function patchComment(comment, changes) { + const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(changes), + }); + if (response.ok) await refreshComments(); + } + + const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed }); + const moveComment = (comment, x, y) => patchComment(comment, { x, y }); + const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned }); + + async function deleteDrawing(item) { + await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' }); + if (item.kind === 'comment') await refreshComments(); + else levels.value = levels.value.filter(level => level.id !== item.id); + syncVisibleLevels(); + } + + async function deleteFilteredDrawings() { + const doomed = [...filteredDrawings.value]; + if (!doomed.length) return; + for (const item of doomed) await deleteDrawing(item); + } + async function createTrendline(first, second) { const [start, end] = [first, second].sort((a, b) => a.t - b.t); const side = start.snappedSide || end.snappedSide || drawSide.value; @@ -327,6 +427,9 @@ createApp({ }); if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; } const saved = await response.json(); + // A comment is not a level and must not be pushed into the level list, + // where it would be drawn as a line. + if (line.kind === 'comment') { await refreshComments(); return; } levels.value = levels.value.map(level => level.id === line.id ? saved : level); syncVisibleLevels(); } @@ -434,6 +537,8 @@ createApp({ chartApi.setLineChangeHandler(updateLineGeometry); chartApi.setLineEndHandler(endLineHere); chartApi.setSnap(snap.value); + chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment }); + refreshComments(); window.addEventListener('keydown', handleKeydown); refreshStatus(); connect(); @@ -446,6 +551,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed }; + return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index db3a4f2..42268b2 100644 --- a/static/chart.js +++ b/static/chart.js @@ -545,6 +545,21 @@ class ConfluenceChart { this.clearGesture(); this.chartEl.releasePointerCapture?.(event.pointerId); + if (tool === 'comment') { + // Both placements come from one gesture: the time and price under the + // cursor for a pinned comment, and the same spot as a fraction of the + // pane for a floating one, so the tool decides which without a second + // interaction. + this.onToolComplete?.({ + tool, + time: end.t, + price: ConfluenceChart.snapToTick(end.p), + x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1), + y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1), + }); + return; + } + if (tool === 'level') { // A click with no drag is a valid placement; the drag is only there to // let you fine-tune the price before committing. diff --git a/static/index.html b/static/index.html index 0feb201..5e1d834 100644 --- a/static/index.html +++ b/static/index.html @@ -88,27 +88,76 @@
Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.
+Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.
+