From 81045e212841b5d82efdfeca37ef898dd26d022c Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 18:59:12 -0500 Subject: [PATCH] trendlines should only show numbers until selected or hover --- static/app.js | 88 ++++++++++++++++++++++-------------- static/chart.js | 83 ++++++++++++++++++++++++++++++---- static/index.html | 10 ++-- static/style.css | 9 +++- tests/e2e/comments.test.mjs | 10 ++++ tests/e2e/drawings.test.mjs | 53 ++++++++++++++++++++++ tests/e2e/trendline.test.mjs | 3 ++ 7 files changed, 205 insertions(+), 51 deletions(-) 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 @@
No drawings match this filter.
+ :class="{selected: selectedDrawings.includes(item.id)}" + @click="focusDrawing(item)"> + :checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`" + @click.stop @change="toggleDrawingSelection(item.id)">
diff --git a/static/style.css b/static/style.css index d27800c..786d886 100644 --- a/static/style.css +++ b/static/style.css @@ -13,7 +13,8 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } -#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } +#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } +@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .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; }.data-freshness { margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; } 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; } @@ -65,6 +66,12 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; 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; } +.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 { + 0% { inset:-11px; border-width:11px; opacity:.65; } + 100% { inset:-1px; border-width:1px; opacity:0; } +} /* 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:'◀ '; } diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs index c98698f..e8f24f1 100644 --- a/tests/e2e/comments.test.mjs +++ b/tests/e2e/comments.test.mjs @@ -70,9 +70,19 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => { const box = await chartBox(page); await placeComment(page, box, 0.5, 0.35); assert.equal((await commentNode(page)).collapsed, false); + await page.fill('input[aria-label="Filter drawings by text"]', TEXT); + const row = page.locator('.trendline-row'); + await row.locator('.drawing-secondary>span').click(); + assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true, + 'selecting the comment row did not point out the comment on the chart'); + 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}"]`); + assert.equal(await row.evaluate(node => node.classList.contains('selected')), true, + 'clicking the chart comment did not select its drawing row'); await page.waitForTimeout(1200); assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it'); assertNoPageErrors(page, assert); diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs index b5557e6..9fadaf3 100644 --- a/tests/e2e/drawings.test.mjs +++ b/tests/e2e/drawings.test.mjs @@ -40,6 +40,12 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing' assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`); assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1, 'the armed state is not represented by a bell'); + const checkbox = row.locator('.line-select'); + if (await checkbox.isChecked()) await checkbox.click(); + await row.locator('.drawing-secondary>span').click(); + await page.waitForFunction(id => window.__chart.selectedLineId === id, created); + assert.equal(await page.locator('.chart-line-focus').isVisible(), true, + 'selecting the drawing did not point out its trendline on the chart'); await row.locator('.color-picker>summary').click(); assert.equal(await row.locator('.color-popover>button[aria-label^="Use color"]').count(), 16, 'the preset palette does not contain 16 colors'); @@ -75,3 +81,50 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing' assertNoPageErrors(page, assert); }); }); + +test('a price level selects and focuses in both directions', { timeout: 180000 }, async () => { + await withChart(async page => { + const label = `e2e level focus ${Date.now()}`; + const price = await page.evaluate(() => window.__chart.bars.at(-1).c); + await armTool(page, 'Price level'); + await page.fill('input[aria-label="Level label"]', label); + await page.fill('input[aria-label="Level price"]', String(price)); + await page.locator('.price-row button[type="submit"]').click(); + await page.fill('input[aria-label="Filter drawings by text"]', label); + const row = page.locator('.trendline-row'); + await row.waitFor(); + const id = await page.evaluate(name => + window.__chart.levels.find(level => level.label === name)?.id || null, label); + assert.ok(id, 'the price level was not created'); + const number = await page.evaluate(levelId => + window.__chart.levels.find(level => level.id === levelId).number, id); + assert.equal(await page.evaluate(levelId => + window.__chart.priceLines.get(levelId).line.options().title, id), `#${number}`, + 'an unselected price level exposed its full name on the price axis'); + + await row.locator('.drawing-secondary>span').click(); + await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id); + assert.equal(await page.evaluate(levelId => + window.__chart.priceLines.get(levelId).line.options().title, id), label, + 'a selected price level did not show its full name on the price axis'); + const focus = page.locator('.chart-line-focus'); + assert.equal(await focus.getAttribute('hidden'), null, 'the selected price level was not highlighted'); + assert.equal(await focus.evaluate(node => getComputedStyle(node).animationName), 'line-focus', + 'the price-level focus animation did not run'); + assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'), + 'the price-level highlight was not horizontal'); + + await page.keyboard.press('Escape'); + const point = await page.evaluate(levelId => { + const chart = window.__chart; + const level = chart.levels.find(value => value.id === levelId); + const plot = chart.plotCanvas().getBoundingClientRect(); + return { x: plot.left + plot.width / 2, y: plot.top + chart.candles.priceToCoordinate(level.anchor_p) }; + }, id); + await page.mouse.click(point.x, point.y); + await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id); + assert.equal(await row.evaluate(node => node.classList.contains('selected')), true, + 'clicking the chart price level did not select its drawing row'); + assertNoPageErrors(page, assert); + }); +}); diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 06b307e..201b146 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -333,6 +333,9 @@ test('the line context menu duplicates by ten bars and deletes through the norma level.kind === 'manual' && level.number > originalLine.number) .sort((a, b) => b.number - a.number)[0]; const original = c.levels.find(level => level.id === originalLine.id); + // Socket snapshots and style edits resync every series. That resync + // must not restore full names to unselected price-axis labels. + c.syncLevels(c.levels); const nearestIndex = time => c.bars.reduce((nearest, bar, index) => Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0); return {