diff --git a/static/app.js b/static/app.js index 884dbed..6c4ba38 100644 --- a/static/app.js +++ b/static/app.js @@ -128,8 +128,8 @@ createApp({ const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const captureBusy = ref(false); const snap = ref(true); - const selectedLine = ref(null); - const selectedLines = ref([]); + const selectedDrawing = ref(null); + const selectedDrawings = ref([]); const alertPrice = ref(null); const alertNote = ref(''); const levelColor = ref('#e0a34a'); @@ -159,8 +159,9 @@ createApp({ }).format(new Date(dataReceivedAt.value)); }); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); - const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); - const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); + const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null); + const allManualSelected = computed(() => manualLines.value.length > 0 + && manualLines.value.every(line => selectedDrawings.value.includes(line.id))); // One list for everything drawn. Lines come from the levels socket, which // keeps them live; comments come from the drawings endpoint. @@ -327,7 +328,8 @@ createApp({ if (!image) throw new Error('Browser did not produce a PNG'); const version = await fetch('/api/version').then(response => response.json()).catch(() => ({})); const bars = chartApi?.bars || []; - const selected = levels.value.find(level => level.id === selectedLine.value) || null; + const item = drawings.value.find(drawing => drawing.id === selectedDrawing.value); + const selected = item?.line || item?.comment || null; const metadata = { url: location.href, timeframe: timeframe.value, @@ -365,13 +367,16 @@ createApp({ function armTool(tool) { armedTool.value = armedTool.value === tool ? null : tool; - selectedLine.value = null; + selectedDrawing.value = null; + selectedDrawings.value = []; chartApi.armTool(armedTool.value); } function handleChartClick(param) { - selectedLine.value = chartApi.hitTest(param); - selectedLines.value = selectedLine.value ? [selectedLine.value] : []; + const id = chartApi.hitTest(param); + selectedDrawing.value = id; + selectedDrawings.value = id ? [id] : []; + if (id) chartApi.focusLine(id); } async function handleToolComplete(result) { @@ -439,6 +444,8 @@ createApp({ 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); + if (selectedDrawing.value === item.id) selectedDrawing.value = null; + selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id); syncVisibleLevels(); } @@ -471,8 +478,8 @@ createApp({ const saved = await response.json(); levels.value = levels.value.filter(level => level.id !== temporaryId); if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved); - selectedLine.value = saved.id; - selectedLines.value = [saved.id]; + selectedDrawing.value = saved.id; + selectedDrawings.value = [saved.id]; } catch (error) { levels.value = levels.value.filter(level => level.id !== temporaryId); console.error('Unable to save line', error); @@ -498,8 +505,12 @@ createApp({ } async function deleteSelected() { - const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean); - await deleteLines(ids); + const ids = selectedDrawings.value.length + ? selectedDrawings.value : [selectedDrawing.value].filter(Boolean); + for (const id of ids) { + const item = drawings.value.find(drawing => drawing.id === id); + if (item) await deleteDrawing(item); + } } async function deleteLine(id) { @@ -551,8 +562,8 @@ createApp({ } const saved = await response.json(); levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; - selectedLine.value = saved.id; - selectedLines.value = [saved.id]; + selectedDrawing.value = saved.id; + selectedDrawings.value = [saved.id]; syncVisibleLevels(); } @@ -560,8 +571,8 @@ createApp({ if (!ids.length) return; const deleting = new Set(ids); levels.value = levels.value.filter(level => !deleting.has(level.id)); - if (selectedLine.value && deleting.has(selectedLine.value)) selectedLine.value = null; - selectedLines.value = selectedLines.value.filter(id => !deleting.has(id)); + if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null; + selectedDrawings.value = selectedDrawings.value.filter(id => !deleting.has(id)); syncVisibleLevels(); const responses = []; for (const id of ids) { @@ -572,25 +583,32 @@ createApp({ }); } - function selectLine(id) { - selectedLine.value = id; - selectedLines.value = [id]; + function focusDrawing(item) { + selectedDrawing.value = item.id; + selectedDrawings.value = [item.id]; + if (item.kind === 'comment') chartApi?.focusComment(item.id); + else chartApi?.focusLine(item.id); } - function toggleLineSelection(id) { - selectedLines.value = selectedLines.value.includes(id) - ? selectedLines.value.filter(value => value !== id) - : [...selectedLines.value, id]; - selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; + function selectComment(comment) { + selectedDrawing.value = comment.id; + selectedDrawings.value = [comment.id]; + } + + function toggleDrawingSelection(id) { + selectedDrawings.value = selectedDrawings.value.includes(id) + ? selectedDrawings.value.filter(value => value !== id) + : [...selectedDrawings.value, id]; + selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null; } function toggleSelectAll() { - selectedLines.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id); - selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; + selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id); + selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null; } async function deleteSelectedLines() { - await deleteLines([...selectedLines.value]); + await deleteLines([...selectedDrawings.value]); } async function setArmed(line, armed) { @@ -665,15 +683,15 @@ createApp({ event.preventDefault(); return; } - if (hasLineSelection.value) { - selectedLine.value = null; - selectedLines.value = []; + if (hasDrawingSelection.value) { + selectedDrawing.value = null; + selectedDrawings.value = []; event.preventDefault(); return; } } if (isEditing(event.target)) return; - if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.value) { + if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) { event.preventDefault(); deleteSelected(); } @@ -722,8 +740,8 @@ createApp({ sendPrefs(); }, { deep: true }); - watch(selectedLine, id => { - if (chartApi) chartApi.setSelectedLine(id); + watch(selectedDrawing, id => { + if (chartApi) chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null); }); watch(snap, value => { @@ -744,7 +762,7 @@ createApp({ chartApi.setLineDuplicateHandler(duplicateLine); chartApi.setLineDeleteHandler(deleteLine); chartApi.setSnap(snap.value); - chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment }); + chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment }); window.addEventListener('keydown', handleKeydown); await migrateLegacyToken(); await refreshStatus(); @@ -759,6 +777,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, 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 }; + return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, 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 }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 599a4a3..7c69b2b 100644 --- a/static/chart.js +++ b/static/chart.js @@ -18,6 +18,8 @@ class ConfluenceChart { this.selectedLineId = null; this.anchorHandles = []; this.lineHitTarget = null; + this.lineFocus = null; + this.lineFocusTimer = null; this.draggingLine = null; this.lineMoveListener = null; this.lineUpListener = null; @@ -56,6 +58,7 @@ class ConfluenceChart { this.contextLabelFrame = null; this.onCommentToggle = null; this.onCommentMove = null; + this.onCommentSelect = null; this.lastSnapDiagnostic = null; } @@ -206,6 +209,10 @@ class ConfluenceChart { const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); handles.classList.add('chart-handles'); handles.setAttribute('aria-hidden', 'true'); + this.lineFocus = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.lineFocus.classList.add('chart-line-focus'); + this.lineFocus.setAttribute('hidden', ''); + handles.appendChild(this.lineFocus); this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.lineHitTarget.classList.add('chart-line-hit'); this.lineHitTarget.setAttribute('hidden', ''); @@ -379,9 +386,10 @@ class ConfluenceChart { this.renderComments(); } - setCommentHandlers({ onToggle, onMove }) { + setCommentHandlers({ onToggle, onMove, onSelect }) { this.onCommentToggle = onToggle; this.onCommentMove = onMove; + this.onCommentSelect = onSelect; } // Comments are DOM, not canvas: they hold arbitrary text, need to collapse, @@ -404,6 +412,7 @@ class ConfluenceChart { 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.style.setProperty('--comment-focus-color', comment.color || '#c8992f'); node.root.title = comment.note; if (!comment.pinned) { @@ -428,6 +437,7 @@ class ConfluenceChart { } for (const [id, node] of this.commentNodes) { if (!seen.has(id)) { + clearTimeout(node.focusTimer); node.root.remove(); this.commentNodes.delete(id); } @@ -467,12 +477,17 @@ class ConfluenceChart { buildCommentNode(comment) { const root = document.createElement('div'); root.className = 'chart-comment'; + const focus = document.createElement('span'); + focus.className = 'chart-comment-focus'; + focus.setAttribute('aria-hidden', 'true'); const label = document.createElement('span'); label.className = 'chart-comment-text'; - root.appendChild(label); - const node = { root, label, comment }; + root.append(focus, label); + const node = { root, focus, label, comment }; root.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. @@ -510,7 +525,7 @@ class ConfluenceChart { seriesTitle(level) { if (ConfluenceChart.isContextLevel(level)) return ''; - if (level.kind === 'manual' && !ConfluenceChart.isFlat(level)) { + if (level.kind === 'manual') { return level.id === this.selectedLineId ? level.label : `#${level.number}`; } return level.label; @@ -612,7 +627,7 @@ class ConfluenceChart { lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null, - title: ConfluenceChart.isContextLevel(level) ? '' : level.label, + title: this.seriesTitle(level), // Overlays outside the visible price range must not flatten the candles. autoscaleInfoProvider: () => null, }; @@ -1119,13 +1134,55 @@ class ConfluenceChart { setSelectedLine(id) { this.selectedLineId = id; this.hideContextMenu(); - for (const level of this.levels.filter(value => - value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { - this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) }); + for (const level of this.levels.filter(value => value.kind === 'manual')) { + if (ConfluenceChart.isFlat(level)) { + this.priceLines.get(level.id)?.line.applyOptions({ title: this.seriesTitle(level) }); + } else { + this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) }); + } } this.renderAnchorHandles(); } + focusLine(id) { + this.setSelectedLine(id); + const level = this.levels.find(value => value.id === id && value.kind === 'manual'); + if (!level) return; + if (ConfluenceChart.isFlat(level)) { + const y = this.candles.priceToCoordinate(level.anchor_p); + if (y == null) return; + this.lineFocus.setAttribute('x1', 0); + this.lineFocus.setAttribute('y1', y); + this.lineFocus.setAttribute('x2', this.overlayLayer.getBoundingClientRect().width); + this.lineFocus.setAttribute('y2', y); + } else { + if (this.lineHitTarget.hasAttribute('hidden')) return; + for (const attribute of ['x1', 'y1', 'x2', 'y2']) { + this.lineFocus.setAttribute(attribute, this.lineHitTarget.getAttribute(attribute)); + } + } + this.lineFocus.setAttribute('stroke', ConfluenceChart.levelColor(level)); + const lineWidth = level.line_width || 2; + this.lineFocus.style.setProperty('--line-focus-start-width', `${lineWidth + 10}px`); + this.lineFocus.style.setProperty('--line-focus-width', `${lineWidth}px`); + this.lineFocus.removeAttribute('hidden'); + this.lineFocus.style.animation = 'none'; + void this.lineFocus.getBoundingClientRect(); + this.lineFocus.style.animation = ''; + clearTimeout(this.lineFocusTimer); + this.lineFocusTimer = setTimeout(() => this.lineFocus.setAttribute('hidden', ''), 800); + } + + focusComment(id) { + const node = this.commentNodes.get(id); + if (!node) return; + clearTimeout(node.focusTimer); + node.root.classList.remove('focus'); + void node.root.getBoundingClientRect(); + node.root.classList.add('focus'); + node.focusTimer = setTimeout(() => node.root.classList.remove('focus'), 800); + } + setLinePreview(start, end, color, lineWidth) { if (start.t === end.t) return; const x1 = this.chart.timeScale().timeToCoordinate(start.t); @@ -1161,8 +1218,12 @@ class ConfluenceChart { let best = null; const visible = this.visibleBarIndexes(); if (!visible) return null; - // Flat levels draw as price lines, which have no series geometry to hit — - // they are selected and removed from the sidebar list instead. + for (const level of this.levels.filter(value => + value.kind === 'manual' && ConfluenceChart.isFlat(value) && !value.hidden)) { + const y = this.candles.priceToCoordinate(level.anchor_p); + const distance = y == null ? Infinity : Math.abs(y - param.point.y); + if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; + } for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); @@ -1521,6 +1582,8 @@ class ConfluenceChart { if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame); + clearTimeout(this.lineFocusTimer); + for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); diff --git a/static/index.html b/static/index.html index 61c2408..212e924 100644 --- a/static/index.html +++ b/static/index.html @@ -31,7 +31,7 @@ HELD {{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }} Drag on the chart from one point to the other Click or drag on the chart to set the price - Line selected — Delete removes it + Drawing selected — Delete removes it @@ -129,11 +129,11 @@