From e95799124251a42e40c1d4ed7e41180fcc5bfad2 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 19:51:39 -0500 Subject: [PATCH] symbol improvements --- app/analysis/manual_lines.py | 4 +- app/api/routes.py | 9 ++- docs/mobile_enhance.md | 15 ++--- static/app.js | 95 ++++++++++++++++++++++++++++--- static/chart.js | 105 +++++++++++++++++++++++++++++++---- static/index.html | 52 ++++++++++++++--- static/style.css | 10 +++- tests/e2e/comments.test.mjs | 72 +++++++++++++++++++++++- tests/test_manual_lines.py | 6 +- 9 files changed, 324 insertions(+), 44 deletions(-) diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 41abb69..6731222 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -32,6 +32,7 @@ class ManualLine: x: float = 0.72 y: float = 0.12 collapsed: bool = False + icon: str = "" @property def drawing_kind(self) -> str: @@ -47,7 +48,7 @@ class ManualLine: @property def is_comment(self) -> bool: - return self.drawing_kind == "comment" + return self.drawing_kind in {"comment", "symbol"} @property def horizontal(self) -> bool: @@ -114,6 +115,7 @@ class ManualLine: x=float(value.get("x", 0.72)), y=float(value.get("y", 0.12)), collapsed=bool(value.get("collapsed", False)), + icon=str(value.get("icon", "")), ) diff --git a/app/api/routes.py b/app/api/routes.py index 22b9acc..4e30b39 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -4,6 +4,7 @@ import json import logging import time import uuid +from typing import Literal from fastapi.responses import FileResponse from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response @@ -219,6 +220,11 @@ class CommentCreate(BaseModel): 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 + icon: Literal[ + "arrow-up", "arrow-down", "face-smile", "hand-point-right", + "skull", "face-laugh-squint", "champagne-glasses", + "arrow-left", "arrow-right", "hand", "right-to-bracket", "play", + ] | None = None @router.post("/comments", status_code=201) @@ -241,7 +247,8 @@ def create_comment(request: Request, payload: CommentCreate): created_at=now, note=payload.text, color=payload.color, - kind="comment", + kind="symbol" if payload.icon else "comment", + icon=payload.icon or "", pinned=payload.pinned, x=payload.x, y=payload.y, diff --git a/docs/mobile_enhance.md b/docs/mobile_enhance.md index 1f8ac52..dc48bad 100644 --- a/docs/mobile_enhance.md +++ b/docs/mobile_enhance.md @@ -40,7 +40,7 @@ improves desktop click-click placement. ### P0: Mobile tool rail next to the chart -On mobile, keep Trendline, Level, Comment, active-tool state, and Cancel in a +On mobile, keep Trendline, Level, Comment, Symbol, active-tool state, and Cancel in a compact sticky rail directly below the chart. Open tool configuration in an expandable panel or sheet. Leave Layers and the complete Drawings manager below. @@ -98,13 +98,14 @@ matter. ### P1: Separate comment actions -Do not make the whole comment simultaneously mean collapse, open, and drag. -Use tap to select/open, an explicit collapse control, and a dedicated drag -handle for floating comments. Expose Edit text, Pin/Float, Collapse, and Delete -through the selected-drawing actions. +Desktop now separates these actions: the body selects, the left-side control +collapses, and a dedicated grip moves floating comments and pinned symbols. +Carry that interaction to touch, with suitably enlarged invisible hit regions. +Expose Edit text, Pin/Float, Collapse, and Delete through the selected-drawing +actions. -Complexity: medium. Desktop click-to-collapse can remain initially if changing -it would be disruptive. +Complexity: medium. The behavior exists; mobile still needs touch-sized targets +and gesture verification. ### P1: Keyboard-safe mobile layout diff --git a/static/app.js b/static/app.js index 0753b9e..a9ed684 100644 --- a/static/app.js +++ b/static/app.js @@ -120,6 +120,9 @@ createApp({ const drawSide = ref('support'); const commentText = ref(''); const commentFloat = ref(false); + const selectedSymbol = ref('arrow-up'); + const symbolColor = ref('#65b7cf'); + const symbolPanelOpen = ref(false); // Comments are not levels, so they never arrive over the levels socket and // are fetched and refreshed explicitly. const comments = ref([]); @@ -148,6 +151,20 @@ createApp({ '#9fb0c4': 'Slate', '#746c60': 'Taupe', '#2c2924': 'Charcoal', '#c8992f': 'Ochre', }; const drawingColorName = color => drawingColorNames[color] || color; + const symbolChoices = [ + { icon: 'arrow-up', name: 'Up arrow' }, + { icon: 'arrow-down', name: 'Down arrow' }, + { icon: 'face-smile', name: 'Smiley' }, + { icon: 'hand-point-right', name: 'Pointing finger' }, + { icon: 'skull', name: 'Skull' }, + { icon: 'face-laugh-squint', name: 'Laughing face' }, + { icon: 'champagne-glasses', name: 'Celebrate' }, + { icon: 'arrow-left', name: 'Left arrow' }, + { icon: 'arrow-right', name: 'Right arrow' }, + { icon: 'hand', name: 'Stop' }, + { icon: 'right-to-bracket', name: 'Enter trade' }, + { icon: 'play', name: 'Go' }, + ]; const now = ref(Date.now()); let chartApi = null; let socket = null; @@ -183,7 +200,7 @@ createApp({ const notes = comments.value.map(comment => ({ id: comment.id, number: comment.number, - kind: 'comment', + kind: comment.kind === 'symbol' ? 'symbol' : 'comment', label: comment.note || '', comment, })); @@ -374,6 +391,7 @@ createApp({ function armTool(tool) { armedTool.value = armedTool.value === tool ? null : tool; + if (tool !== 'symbol') symbolPanelOpen.value = false; selectedDrawing.value = null; selectedDrawings.value = []; chartApi.armTool(armedTool.value); @@ -387,13 +405,18 @@ createApp({ } async function handleToolComplete(result) { - // One placement per arming, so a tool cannot keep firing on stray drags. + // Placement is one-shot even though the symbol palette remains open. The + // next gesture should select or adjust the mark, not stamp another one. armedTool.value = null; chartApi.armTool(null); if (result.tool === 'comment') { await createComment(result); return; } + if (result.tool === 'symbol') { + await createSymbol(result); + return; + } if (result.tool === 'level') { alertPrice.value = result.price; await addPriceAlert(); @@ -406,7 +429,7 @@ createApp({ const response = await apiFetch('/api/drawings'); if (!response.ok) return; const payload = await response.json(); - comments.value = (payload.drawings || []).filter(item => item.kind === 'comment'); + comments.value = (payload.drawings || []).filter(item => ['comment', 'symbol'].includes(item.kind)); syncComments(); } @@ -435,6 +458,57 @@ createApp({ await refreshComments(); } + async function createSymbol(result, icon = selectedSymbol.value) { + const choice = symbolChoices.find(symbol => symbol.icon === icon); + if (!choice) return; + const response = await apiFetch('/api/comments', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: choice.name, icon, color: symbolColor.value, pinned: true, + anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y, + }), + }); + if (!response.ok) { + console.error('Unable to save symbol', response.status); + return; + } + await refreshComments(); + } + + function chooseSymbol(symbol) { + selectedSymbol.value = symbol.icon; + armedTool.value = 'symbol'; + chartApi.armTool('symbol'); + } + + function toggleSymbolPanel() { + symbolPanelOpen.value = !symbolPanelOpen.value; + if (symbolPanelOpen.value) { + armedTool.value = 'symbol'; + chartApi.armTool('symbol'); + } else if (armedTool.value === 'symbol') { + armedTool.value = null; + chartApi.armTool(null); + } + } + + function startSymbolDrag(event, symbol) { + selectedSymbol.value = symbol.icon; + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('text/x-chart-symbol', symbol.icon); + } + + async function dropSymbol(event) { + const icon = event.dataTransfer.getData('text/x-chart-symbol'); + const result = chartApi?.placementAtClient(event.clientX, event.clientY); + if (!icon || !result) return; + await createSymbol(result, icon); + if (armedTool.value === 'symbol') { + armedTool.value = null; + chartApi.armTool(null); + } + } + async function patchComment(comment, changes) { const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -444,12 +518,12 @@ createApp({ } const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed }); - const moveComment = (comment, x, y) => patchComment(comment, { x, y }); + const moveComment = (comment, changes) => patchComment(comment, changes); 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(); + if (item.comment) await refreshComments(); else levels.value = levels.value.filter(level => level.id !== item.id); if (selectedDrawing.value === item.id) selectedDrawing.value = null; selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id); @@ -593,7 +667,7 @@ createApp({ function focusDrawing(item) { selectedDrawing.value = item.id; selectedDrawings.value = [item.id]; - if (item.kind === 'comment') chartApi?.focusComment(item.id); + if (item.comment) chartApi?.focusComment(item.id); else chartApi?.focusLine(item.id); } @@ -635,7 +709,7 @@ createApp({ 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; } + if (['comment', 'symbol'].includes(line.kind)) { await refreshComments(); return; } levels.value = levels.value.map(level => level.id === line.id ? saved : level); syncVisibleLevels(); } @@ -748,7 +822,10 @@ createApp({ }, { deep: true }); watch(selectedDrawing, id => { - if (chartApi) chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null); + if (chartApi) { + chartApi.setSelectedDrawing(id); + chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null); + } }); watch(snap, value => { @@ -784,6 +861,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; + return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 7c69b2b..1770a7b 100644 --- a/static/chart.js +++ b/static/chart.js @@ -16,6 +16,7 @@ class ConfluenceChart { this.chartEl = null; this.clickListener = null; this.selectedLineId = null; + this.selectedDrawingId = null; this.anchorHandles = []; this.lineHitTarget = null; this.lineFocus = null; @@ -408,9 +409,22 @@ class ConfluenceChart { this.commentLayer.appendChild(node.root); } node.comment = comment; + node.root.dataset.drawingId = comment.id; node.root.classList.toggle('collapsed', !!comment.collapsed); node.root.classList.toggle('floating', !comment.pinned); - node.label.textContent = comment.collapsed ? String(comment.number) : comment.note; + const iconClass = ConfluenceChart.symbolIcons[comment.icon]; + node.root.classList.toggle('symbol', !!iconClass); + node.collapse.hidden = !!iconClass; + node.collapseIcon.className = comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'; + node.grab.hidden = iconClass + ? this.selectedDrawingId !== comment.id + : comment.pinned; + if (iconClass) { + node.icon.className = `fa-solid ${iconClass}`; + node.label.replaceChildren(node.icon); + } else { + node.label.textContent = comment.collapsed ? String(comment.number) : comment.note; + } node.root.style.borderColor = comment.color; node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f'); node.root.title = comment.note; @@ -482,30 +496,68 @@ class ConfluenceChart { focus.setAttribute('aria-hidden', 'true'); const label = document.createElement('span'); label.className = 'chart-comment-text'; - root.append(focus, label); - const node = { root, focus, label, comment }; + const icon = document.createElement('i'); + const collapse = document.createElement('button'); + collapse.type = 'button'; + collapse.className = 'chart-comment-collapse'; + collapse.title = 'Collapse or expand'; + collapse.setAttribute('aria-label', 'Collapse or expand comment'); + const collapseIcon = document.createElement('i'); + collapse.appendChild(collapseIcon); + const grab = document.createElement('button'); + grab.type = 'button'; + grab.className = 'chart-comment-grab'; + grab.title = 'Drag'; + grab.setAttribute('aria-label', 'Drag drawing'); + const grabIcon = document.createElement('i'); + grabIcon.className = 'fa-solid fa-grip-vertical'; + grab.appendChild(grabIcon); + root.append(focus, collapse, grab, label); + const node = { root, focus, label, icon, collapse, collapseIcon, grab, comment }; root.addEventListener('click', event => { event.stopPropagation(); this.focusComment(node.comment.id); if (this.onCommentSelect) this.onCommentSelect(node.comment); + }); + collapse.addEventListener('click', event => { + event.stopPropagation(); + this.focusComment(node.comment.id); + if (this.onCommentSelect) this.onCommentSelect(node.comment); 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; + grab.addEventListener('click', event => event.stopPropagation()); + // Floating comments move in screen space. Symbols can also be moved while + // pinned, in which case their bar and tick-snapped price are updated. + grab.addEventListener('pointerdown', event => { + if (node.comment.pinned && !node.comment.icon) return; + event.preventDefault(); event.stopPropagation(); - const bounds = this.chartEl.getBoundingClientRect(); + this.focusComment(node.comment.id); + if (this.onCommentSelect) this.onCommentSelect(node.comment); + const bounds = this.plotCanvas().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`; + root.style.left = `${Math.round(moveEvent.clientX - bounds.left - root.offsetWidth / 2)}px`; + root.style.top = `${Math.round(moveEvent.clientY - bounds.top - root.offsetHeight / 2)}px`; }; const up = upEvent => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); + if (node.comment.pinned && node.comment.icon) { + const point = this.eventPoint(upEvent); + if (point && this.withinPlot(point) && this.onCommentMove) { + this.onCommentMove(node.comment, { + anchor_t: Math.round(point.t), + anchor_p: ConfluenceChart.snapToTick(point.p), + }); + } else { + this.renderComments(); + } + return; + } 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)); + if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) }); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); @@ -804,6 +856,17 @@ class ConfluenceChart { return { x, y, p: price, t: time == null ? projectedTime : Number(time) }; } + placementAtClient(clientX, clientY) { + const point = this.eventPoint({ clientX, clientY }); + if (!point || !this.withinPlot(point)) return null; + return { + time: Math.round(point.t), + price: ConfluenceChart.snapToTick(point.p), + x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1), + y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1), + }; + } + indexAtCoordinate(x) { if (!this.bars.length) return null; const scale = this.chart.timeScale(); @@ -966,7 +1029,7 @@ class ConfluenceChart { this.clearGesture(); this.chartEl.releasePointerCapture?.(event.pointerId); - if (tool === 'comment') { + if (tool === 'comment' || tool === 'symbol') { // 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 @@ -1144,6 +1207,11 @@ class ConfluenceChart { this.renderAnchorHandles(); } + setSelectedDrawing(id) { + this.selectedDrawingId = id; + this.renderComments(); + } + focusLine(id) { this.setSelectedLine(id); const level = this.levels.find(value => value.id === id && value.kind === 'manual'); @@ -1606,6 +1674,21 @@ ConfluenceChart.tfColors = { // colours they would be indistinguishable from the daily moving averages. ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' }; +ConfluenceChart.symbolIcons = { + 'arrow-up': 'fa-arrow-up', + 'arrow-down': 'fa-arrow-down', + 'face-smile': 'fa-face-smile', + 'hand-point-right': 'fa-hand-point-right', + skull: 'fa-skull', + 'face-laugh-squint': 'fa-face-laugh-squint', + 'champagne-glasses': 'fa-champagne-glasses', + 'arrow-left': 'fa-arrow-left', + 'arrow-right': 'fa-arrow-right', + hand: 'fa-hand', + 'right-to-bracket': 'fa-right-to-bracket', + play: 'fa-play', +}; + ConfluenceChart.levelColor = level => level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf]; diff --git a/static/index.html b/static/index.html index 875f734..58175ab 100644 --- a/static/index.html +++ b/static/index.html @@ -24,7 +24,7 @@
{{ status.symbol || 'ES=F' }}{{ price == null ? '—' : price.toFixed(2) }}
-
+
FEED {{ status.stream }} LAST BAR {{ barAge }} @@ -106,6 +106,42 @@

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.

+
+
+ +
+ +
+ + + +
+
+ {{ armedTool === 'symbol' ? 'click chart' : '' }} +
+
+
+ +
+
+
- diff --git a/static/style.css b/static/style.css index 786d886..162645a 100644 --- a/static/style.css +++ b/static/style.css @@ -34,6 +34,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; background:transparent; color:var(--fg); border:0; cursor:pointer; text-align:left; } .tool-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); } .tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, transparent); } +.symbol-head { cursor:default; }.symbol-arm { display:flex; align-items:center; gap:8px; flex:1; min-width:0; padding:0; border:0; color:var(--fg); text-align:left; }.symbol-head-color { width:24px; height:22px; }.symbol-head-color>summary { width:22px; height:20px; }.symbol-head-color .color-popover { top:27px; right:0; bottom:auto; }.tool:has(.symbol-head) { overflow:visible; } .tool-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; } .tool.armed .tool-glyph { color:var(--accent); } .tool-state { margin-left:auto; font-size:10px; color:var(--accent); } @@ -43,6 +44,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .tool-body input, .tool-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); } .tool-body input[type=color] { padding:2px; height:26px; } +.symbol-palette { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }.symbol-palette button { display:grid; place-items:center; min-width:0; height:30px; padding:0; font-size:14px; }.symbol-palette button.active { color:var(--bg); } .tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; } .tool-body .check input { width:auto; } .price-row { grid-template-columns:1fr auto; } @@ -62,10 +64,12 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; 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; +.chart-comment.collapsed { max-width:none; width:34px; height:20px; padding:0; display:flex; + align-items:center; justify-content:flex-end; border-radius:4px; border-left-width:1px; font-size:9px; white-space:nowrap; } -.chart-comment.floating { border-style:dashed; cursor:grab; } +.chart-comment.floating { border-style:dashed; } +.chart-comment:not(.symbol) { padding-left:19px; }.chart-comment-collapse { position:absolute; inset:0 auto 0 0; display:grid; place-items:center; width:14px; padding:0; border:0; border-right:1px solid var(--line); color:var(--muted); font-size:7px; }.chart-comment-collapse[hidden],.chart-comment-grab[hidden] { display:none; }.chart-comment-grab { position:absolute; right:-9px; top:-9px; z-index:2; display:grid; place-items:center; width:18px; height:18px; padding:0; border:1px solid var(--line); border-radius:50%; background:var(--panel); color:var(--muted); font-size:8px; cursor:grab; opacity:.7; }.chart-comment-grab:hover { opacity:1; color:var(--fg); }.chart-comment-grab:active { cursor:grabbing; } +.chart-comment.floating { cursor:pointer; }.chart-comment.symbol { display:grid; place-items:center; width:30px; height:30px; padding:0; border-radius:50%; color:var(--comment-focus-color); font-size:18px; cursor:pointer; }.chart-comment.symbol .chart-comment-text { display:grid; place-items:center; } .chart-comment-focus { position:absolute; inset:-1px; border:1px solid var(--comment-focus-color); border-radius:inherit; opacity:0; pointer-events:none; } .chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; } @keyframes comment-focus { diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs index e8f24f1..4ff0aca 100644 --- a/tests/e2e/comments.test.mjs +++ b/tests/e2e/comments.test.mjs @@ -78,11 +78,15 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => { await page.keyboard.press('Escape'); assert.equal(await row.evaluate(node => node.classList.contains('selected')), false, 'Escape did not clear comment selection'); - // By title, not by class: the dev store is shared, so other people's - // comments are on the chart too and `.chart-comment` matches them first. - await page.click(`.chart-comment[title="${TEXT}"]`); + const chartComment = page.locator(`.chart-comment[title="${TEXT}"]`); + // The body selects without collapsing; collapse is deliberately confined + // to the narrow control on the left. + await chartComment.locator('.chart-comment-text').click(); assert.equal(await row.evaluate(node => node.classList.contains('selected')), true, 'clicking the chart comment did not select its drawing row'); + assert.equal((await commentNode(page)).collapsed, false, + 'selecting the comment also collapsed it'); + await chartComment.locator('.chart-comment-collapse').click(); await page.waitForTimeout(1200); assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it'); assertNoPageErrors(page, assert); @@ -118,3 +122,65 @@ test('a comment is never a level', { timeout: 180000 }, async () => { assertNoPageErrors(page, assert); }); }); + +test('a symbol can be dropped at a price and dragged to a new one', { timeout: 180000 }, async () => { + await withChart(async page => { + const box = await chartBox(page); + await armTool(page, 'Symbol'); + assert.equal(await page.locator('.symbol-palette>button').count(), 12, + 'the two-row symbol palette is incomplete'); + assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1, + 'the Go symbol is not represented by a play triangle'); + await page.locator('.symbol-head-color>summary').click(); + assert.equal(await page.locator('.symbol-head-color .color-popover>button[title="Cyan"]').count(), 1, + 'the symbol color palette does not expose named presets'); + assert.equal(await page.locator('.symbol-head-color .color-popover>button[aria-label^="Use "]').count(), 16, + 'the symbol color palette does not contain all presets'); + await page.locator('.symbol-head-color .palette-close').click(); + await page.locator('button[aria-label="Skull"]').dragTo(page.locator('#chart'), { + targetPosition: { x: box.w * 0.55, y: box.h * 0.4 }, + }); + await page.waitForFunction(() => window.__chart.comments.some(comment => + comment.kind === 'symbol' && comment.icon === 'skull')); + const symbolTool = page.locator('.tool:has(.symbol-head)'); + assert.equal(await symbolTool.locator('.tool-body').isVisible(), true, + 'placing a symbol closed its tool palette'); + assert.equal(await symbolTool.evaluate(node => node.classList.contains('armed')), false, + 'placing a symbol left the chart armed to create another one'); + const created = await page.evaluate(() => { + const symbol = window.__chart.comments.find(comment => + comment.kind === 'symbol' && comment.icon === 'skull'); + return { id: symbol.id, anchor_t: symbol.anchor_t, anchor_p: symbol.anchor_p }; + }); + assert.equal(created.anchor_p * 4, Math.round(created.anchor_p * 4), + 'the dropped symbol price was not tick-snapped'); + const node = page.locator(`.chart-comment.symbol[data-drawing-id="${created.id}"]`); + assert.equal(await node.locator('.fa-skull').count(), 1, + 'the symbol did not render with its approved Font Awesome icon'); + assert.equal(await page.evaluate(id => + window.__chart.levels.some(level => level.id === id), created.id), false, + 'the symbol leaked into chart levels'); + + assert.equal(await node.locator('.chart-comment-grab').isVisible(), false, + 'an unselected symbol exposed its grab handle'); + await node.locator('.chart-comment-text').click(); + assert.equal(await node.locator('.chart-comment-grab').isVisible(), true, + 'a selected symbol did not expose its grab handle'); + const before = await node.boundingBox(); + const grip = await node.locator('.chart-comment-grab').boundingBox(); + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await page.mouse.down(); + await page.mouse.move(grip.x + grip.width / 2 + 70, grip.y + grip.height / 2 + 45, + { steps: 10 }); + await page.mouse.up(); + await page.waitForFunction(previous => { + const symbol = window.__chart.comments.find(comment => comment.id === previous.id); + return symbol && (symbol.anchor_t !== previous.anchor_t || symbol.anchor_p !== previous.anchor_p); + }, created); + const movedPrice = await page.evaluate(id => + window.__chart.comments.find(comment => comment.id === id).anchor_p, created.id); + assert.equal(movedPrice * 4, Math.round(movedPrice * 4), + 'the dragged symbol price was not tick-snapped'); + assertNoPageErrors(page, assert); + }); +}); diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index b950a22..a6b8153 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -52,7 +52,7 @@ def test_manual_line_raises_existing_ma_cluster_score(): assert after.score == 16 -def test_a_comment_is_never_a_level(tmp_path): +def test_an_annotation_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. @@ -66,9 +66,11 @@ def test_a_comment_is_never_a_level(tmp_path): 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)) + store.add(ManualLine(id="ml_symbol", slope=0.0, kind="symbol", + icon="skull", note="Skull", **common)) assert [level.id for level in store.levels()] == ["ml_level"] - assert [line.id for line in store.drawings()] == ["ml_level", "ml_note"] + assert [line.id for line in store.drawings()] == ["ml_level", "ml_note", "ml_symbol"] def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):