better select and visibility

This commit is contained in:
Chris Amow 2026-08-14 03:33:53 -05:00
parent 087d9d9d2a
commit a09e4f1208
6 changed files with 94 additions and 33 deletions

View file

@ -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');

View file

@ -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', ''));

View file

@ -182,18 +182,20 @@
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div>
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
:title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
Delete shown ({{ filteredDrawings.length }})
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
<span class="selection-count">{{ selectedDrawings.length }} selected</span>
<button @click="toggleSelectedVisibility" :disabled="!selectedDrawings.length"
:aria-label="selectedAreHidden ? 'Show selected drawings' : 'Hide selected drawings'">
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
{{ selectedAreHidden ? 'Show' : 'Hide' }}
</button>
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
</div>
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedDrawings.includes(item.id)}"
@click="focusDrawing(item)">
:class="{selected: selectedDrawings.includes(item.id), 'hidden-drawing': item.hidden}">
<input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)">

View file

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

View file

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

View file

@ -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);
});
});