From a09e4f12089162f8d158658e2d0bc7601ba866a1 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Fri, 14 Aug 2026 03:33:53 -0500 Subject: [PATCH] better select and visibility --- static/app.js | 46 +++++++++++++++++--------------- static/chart.js | 2 +- static/index.html | 14 +++++----- static/style.css | 4 +-- tests/e2e/comments.test.mjs | 9 ++++--- tests/e2e/drawings.test.mjs | 52 +++++++++++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 33 deletions(-) diff --git a/static/app.js b/static/app.js index 8bfa556..0a04527 100644 --- a/static/app.js +++ b/static/app.js @@ -216,9 +216,6 @@ createApp({ }); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null); - const allManualSelected = computed(() => manualLines.value.length + comments.value.length > 0 - && [...manualLines.value, ...comments.value] - .every(drawing => selectedDrawings.value.includes(drawing.id))); // One list for everything drawn. Lines come from the levels socket, which // keeps them live; comments come from the drawings endpoint. @@ -234,6 +231,7 @@ createApp({ number: line.number, kind: line.slope === 0 ? 'level' : 'trendline', label: line.label || '', + hidden: line.hidden, line, })); const notes = comments.value.map(comment => ({ @@ -241,6 +239,7 @@ createApp({ number: comment.number, kind: comment.kind === 'symbol' ? 'symbol' : 'comment', label: comment.note || '', + hidden: comment.hidden, comment, })); return [...lines, ...notes].sort((a, b) => a.number - b.number); @@ -253,9 +252,14 @@ createApp({ if (!needle) return true; // Matched against what is on screen — the label or comment text, the // kind, and the drawing number, so "comment", "cpi" and "7" all work. - return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle); + const timeframe = item.line?.tf || item.comment?.tf || ''; + return `${item.label} ${item.kind} ${timeframe} #${item.number}`.toLowerCase().includes(needle); }); }); + const allShownSelected = computed(() => filteredDrawings.value.length > 0 + && filteredDrawings.value.every(drawing => selectedDrawings.value.includes(drawing.id))); + const selectedAreHidden = computed(() => selectedDrawings.value.length > 0 + && selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden)); function addEvent(message, kind = 'info', url = null, number = null) { events.value = [ @@ -581,12 +585,6 @@ createApp({ syncVisibleLevels(); } - async function deleteFilteredDrawings() { - const doomed = [...filteredDrawings.value]; - if (!doomed.length) return; - for (const item of doomed) await deleteDrawing(item); - } - async function createTrendline(first, second) { const [start, end] = [first, second].sort((a, b) => a.t - b.t); const side = start.snappedSide || end.snappedSide || drawSide.value; @@ -717,13 +715,6 @@ createApp({ }); } - function focusDrawing(item) { - selectedDrawing.value = item.id; - selectedDrawings.value = [item.id]; - if (item.comment) chartApi?.focusComment(item.id); - else chartApi?.focusLine(item.id); - } - function selectComment(comment) { selectedDrawing.value = comment.id; selectedDrawings.value = [comment.id]; @@ -734,15 +725,28 @@ createApp({ ? selectedDrawings.value.filter(value => value !== id) : [...selectedDrawings.value, id]; selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null; + if (selectedDrawing.value === id) { + const item = drawings.value.find(drawing => drawing.id === id); + if (item?.comment) chartApi?.focusComment(id); + else chartApi?.focusLine(id); + } } function toggleSelectAll() { - selectedDrawings.value = allManualSelected.value ? [] : drawings.value.map(drawing => drawing.id); + const shown = new Set(filteredDrawings.value.map(drawing => drawing.id)); + selectedDrawings.value = allShownSelected.value + ? selectedDrawings.value.filter(id => !shown.has(id)) + : [...new Set([...selectedDrawings.value, ...shown])]; selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null; } - async function deleteSelectedLines() { - await deleteLines([...selectedDrawings.value]); + async function toggleSelectedVisibility() { + const hidden = !selectedAreHidden.value; + for (const id of [...selectedDrawings.value]) { + const item = drawings.value.find(drawing => drawing.id === id); + const drawing = item?.line || item?.comment; + if (drawing) await updateLineStyle(drawing, { hidden }); + } } async function setArmed(line, armed) { @@ -1009,6 +1013,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 50b914a..7dd9ad3 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1689,7 +1689,7 @@ class ConfluenceChart { renderAnchorHandles() { const level = this.levels.find( - value => value.id === this.selectedLineId && value.kind === 'manual', + value => value.id === this.selectedLineId && value.kind === 'manual' && !value.hidden, ); if (!level || !this.anchorHandles.length) { this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); diff --git a/static/index.html b/static/index.html index 7e928e6..3156506 100644 --- a/static/index.html +++ b/static/index.html @@ -182,18 +182,20 @@
- - + {{ selectedDrawings.length }} selected + +
Nothing drawn yet.
No drawings match this filter.
+ :class="{selected: selectedDrawings.includes(item.id), 'hidden-drawing': item.hidden}"> diff --git a/static/style.css b/static/style.css index 88cb6b2..4d7da0a 100644 --- a/static/style.css +++ b/static/style.css @@ -20,8 +20,8 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.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; } .sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } -.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; } -.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; } +.trendline-actions { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:4px; margin-bottom:7px; }.trendline-actions button { padding:3px 5px; font-size:8px; white-space:nowrap; }.selection-count { color:var(--muted); font-size:8px; text-align:center; white-space:nowrap; } +.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; } .drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; } .drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); } .drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:95px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover>.palette-close { grid-column:4; background:var(--chart-bg); color:var(--muted); }.color-popover>.palette-close:hover { color:var(--fg); }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; } diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs index 6ff60cd..ace056e 100644 --- a/tests/e2e/comments.test.mjs +++ b/tests/e2e/comments.test.mjs @@ -54,7 +54,7 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as assert.ok(anchor, 'the comment was not created'); await page.fill('input[aria-label="Filter drawings by text"]', TEXT); - await page.locator('.trendline-row').click(); + await page.locator('.trendline-row .line-select').check(); await page.keyboard.press('ArrowUp'); await page.keyboard.press('Shift+ArrowRight'); await page.waitForFunction(([text, original]) => { @@ -88,8 +88,11 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => { assert.deepEqual(controls, ['collapse-toggle', 'color'], 'the comment color picker is not the rightmost control'); await row.locator('.drawing-secondary>span').click(); + assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), false, + 'clicking drawing-list content selected the comment'); + await row.locator('.line-select').check(); 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'); + 'selecting the comment checkbox 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'); @@ -115,7 +118,7 @@ test('a floating comment holds its position while the chart scrolls', { timeout: const before = await commentNode(page); assert.ok(before, 'the floating comment was not created'); await page.fill('input[aria-label="Filter drawings by text"]', TEXT); - await page.locator('.trendline-row').click(); + await page.locator('.trendline-row .line-select').check(); await page.keyboard.press('ArrowRight'); await page.waitForFunction(([text, left]) => { const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text); diff --git a/tests/e2e/drawings.test.mjs b/tests/e2e/drawings.test.mjs index 006aa00..b78d150 100644 --- a/tests/e2e/drawings.test.mjs +++ b/tests/e2e/drawings.test.mjs @@ -59,6 +59,9 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing' const checkbox = row.locator('.line-select'); if (await checkbox.isChecked()) await checkbox.click(); await row.locator('.drawing-secondary>span').click(); + assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null, + 'clicking drawing-list content selected the trendline'); + await checkbox.check(); 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'); @@ -160,6 +163,9 @@ test('a price level can be edited from the list and adjusted on the chart', 'an unselected price level exposed its full name on the price axis'); await row.locator('.drawing-secondary>span').click(); + assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null, + 'clicking drawing-list content selected the drawing'); + await row.locator('.line-select').check(); 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, @@ -241,3 +247,49 @@ test('Delete removes a drawing selected through its checkbox', assertNoPageErrors(page, assert); }); }); + +test('filtered selections can hide and show drawings without deleting them', + { timeout: 180000 }, async () => { + await withChart(async page => { + const label = `bulk visibility ${Date.now()}`; + const created = await page.evaluate(async name => { + const chart = window.__chart; + const start = chart.bars.at(-30); + const end = chart.bars.at(-10); + const response = await fetch('/api/lines', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tf: '1m', side: 'support', + anchor_t: start.t, anchor_p: start.l, + end_t: end.t, end_p: end.l, + note: name, + }), + }); + return response.json(); + }, label); + await page.waitForFunction(id => window.__chart.levels.some(level => level.id === id), created.id); + + const filter = page.locator('input[aria-label="Filter drawings by text"]'); + await filter.fill('1m'); + assert.equal(await page.locator(`input[aria-label="Drawing name"][value="${label}"]`).count(), 1, + 'the drawing filter did not match the timeframe'); + + await filter.fill(label); + const row = page.locator('.trendline-row'); + await page.getByRole('button', { name: 'Select shown' }).click(); + assert.equal(await page.locator('.selection-count').textContent(), '1 selected'); + await page.getByRole('button', { name: 'Hide selected drawings' }).click(); + await page.waitForFunction(id => window.__chart.levels.find(level => level.id === id)?.hidden, created.id); + assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), true); + assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), false, + 'the hidden drawing remained on the chart'); + + await page.getByRole('button', { name: 'Show selected drawings' }).click(); + await page.waitForFunction(id => !window.__chart.levels.find(level => level.id === id)?.hidden, created.id); + assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), false); + assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), true, + 'the shown drawing did not return to the chart'); + assertNoPageErrors(page, assert); + }); + });