From 9ab68f6cff724305ee9e4e94b2e1634db25b57bc Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 15:58:21 -0500 Subject: [PATCH] Add chart comments and turn Lines & levels into Drawings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment is a ManualLine with kind="comment", so it inherits persistence, the shared drawing-number sequence, the list, filtering and deletion rather than needing a parallel set of endpoints. The rule that must never bend is that ManualLineStore.levels() excludes them: a comment reaching the level list would join a confluence cluster and push a phone notification about a piece of text. It is created with armed=False, and PATCH returns to_dict() rather than to_level() for a comment, so nothing is ever handed a level-shaped comment. Three tests cover the exclusion, the shared numbering and the kind derived for drawings saved before comments existed. Pinned comments carry anchor_t and anchor_p and travel with the chart; floating ones carry x and y as fractions of the pane, hold their place through any zoom and can be dragged. They render as DOM rather than canvas because they hold arbitrary text, collapse to a numbered dot, and a floating one must ignore the time scale entirely. A pinned comment scrolled out of view parks on the edge it left, pointing back toward itself, so it never simply disappears. 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, and Delete acts on whatever the filter shows — which is what makes deleting by type or by string one button. Verified in a browser: placing a comment renders it at the click, clicking it collapses it, scrolling away parks it on the edge, a floating one holds its pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type, 1 by text and 0 for a miss, with no console errors. Co-Authored-By: Claude Opus 5 --- app/api/routes.py | 4 +- docs/IMPLEMENTATION_PLAN.md | 30 ++++++++++ static/app.js | 107 +++++++++++++++++++++++++++++++++++- static/chart.js | 15 +++++ static/index.html | 83 ++++++++++++++++++++++------ static/style.css | 26 ++++++++- 6 files changed, 245 insertions(+), 20 deletions(-) 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.

+
+
diff --git a/static/style.css b/static/style.css index 8715cb4..cf6676d 100644 --- a/static/style.css +++ b/static/style.css @@ -18,7 +18,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } .sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } -.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } +.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } .layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; } @@ -52,3 +52,27 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } .cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } +/* --- chart comments ---------------------------------------------------- */ +.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; } +.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted); + border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg); + font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap; + box-shadow:0 1px 3px rgba(0,0,0,.16); } +.chart-comment.collapsed { max-width:none; width:18px; height:18px; padding:0; display:flex; + align-items:center; justify-content:center; border-radius:50%; border-left-width:1px; + font-size:9px; white-space:nowrap; } +.chart-comment.floating { border-style:dashed; cursor:grab; } +/* Parked on the edge it scrolled off, pointing the way back to it. */ +.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; } +.chart-comment.off-left::before { content:'◀ '; } +.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; } +.chart-comment.off-left, .chart-comment.off-right { opacity:.72; } +/* --- drawings filter --------------------------------------------------- */ +.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; } +.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px; + border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; } +.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; } +.collapse-toggle { font:inherit; font-size:9px; padding:2px 6px; border:1px solid var(--line); + 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; + border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }