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 @@