From 54f4871757ba38378833e20302dd004b131b1819 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Thu, 13 Aug 2026 19:02:49 -0500 Subject: [PATCH] key nudge for all drawing types --- docs/implementation.md | 9 ++++ static/app.js | 88 ++++++++++++++++++++++++++++++++---- static/chart.js | 11 ----- static/index.html | 3 +- tests/e2e/comments.test.mjs | 22 ++++++++- tests/e2e/trendline.test.mjs | 17 ++++++- 6 files changed, 125 insertions(+), 25 deletions(-) diff --git a/docs/implementation.md b/docs/implementation.md index dd28bca..2e71477 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -796,6 +796,15 @@ levels before price falls into them. Blank retains the ATR default. Backend tests pin both approach directions and persistence; the browser test pins list editing, snapped handle movement and a one-tick keyboard nudge. +Arrow-key nudging now applies to every drawing type. Up/Down shifts levels, +both trendline endpoints, and pinned annotations by one ES tick; Left/Right +shifts trendlines and pinned annotations by one visible-timeframe bar. Floating +comments move four screen pixels instead because they deliberately have no +market coordinates. Shift multiplies each movement by four. Every drawing row, +including comments and symbols, has a selection checkbox so a mixed selection +can move together. Repeated keypresses are serialized: without that, PATCH +responses can arrive out of order and a held arrow can move a drawing backward. + ### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed Trendlines project into the whitespace right of the last candle, but the axis diff --git a/static/app.js b/static/app.js index e5b13f6..a0b68d5 100644 --- a/static/app.js +++ b/static/app.js @@ -171,6 +171,7 @@ createApp({ let socket = null; let retryDelay = 2000; let timer = null; + let nudgeQueue = Promise.resolve(); const barAge = computed(() => { if (!status.value.last_bar_t) return '—'; @@ -185,8 +186,9 @@ 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 > 0 - && manualLines.value.every(line => selectedDrawings.value.includes(line.id))); + 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. @@ -695,7 +697,7 @@ createApp({ } function toggleSelectAll() { - selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id); + selectedDrawings.value = allManualSelected.value ? [] : drawings.value.map(drawing => drawing.id); selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null; } @@ -746,6 +748,75 @@ createApp({ syncVisibleLevels(); } + async function updateCommentGeometry(comment, changes) { + const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(changes), + }); + if (!response.ok) { console.error(`Unable to move drawing: HTTP ${response.status}`); return; } + const saved = await response.json(); + comments.value = comments.value.map(value => value.id === comment.id ? saved : value); + syncComments(); + } + + async function nudgeSelectedDrawings(key, multiplier) { + const ids = selectedDrawings.value.length + ? [...selectedDrawings.value] : [selectedDrawing.value].filter(Boolean); + if (!ids.length || !chartApi) return; + const vertical = key === 'ArrowUp' ? multiplier : key === 'ArrowDown' ? -multiplier : 0; + const horizontal = key === 'ArrowRight' ? multiplier : key === 'ArrowLeft' ? -multiplier : 0; + for (const id of ids) { + const item = drawings.value.find(value => value.id === id); + if (!item) continue; + if (item.line) { + const line = { ...item.line }; + if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK); + if (line.slope === 0) { + if (vertical) await updateLineGeometry(line); + continue; + } + const endPrice = item.line.anchor_p + + item.line.slope * (item.line.last_t - item.line.anchor_t) + + vertical * ConfluenceChart.TICK; + if (horizontal) { + line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal); + line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_t) + horizontal); + if (line.cutoff_t != null) { + line.cutoff_t = chartApi.timeAtIndex(chartApi.indexAt(line.cutoff_t) + horizontal); + } + } + line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); + await updateLineGeometry(line); + continue; + } + const comment = item.comment; + if (comment.pinned) { + const changes = {}; + if (vertical) { + changes.anchor_p = ConfluenceChart.snapToTick( + comment.anchor_p + vertical * ConfluenceChart.TICK, + ); + } + if (horizontal) { + changes.anchor_t = chartApi.timeAtIndex( + chartApi.indexAt(comment.anchor_t) + horizontal, + ); + } + if (Object.keys(changes).length) await updateCommentGeometry(comment, changes); + } else { + if (!chartApi.chartEl) continue; + const pixels = 4 * multiplier; + const clamp = value => Math.min(Math.max(value, 0), 1); + await updateCommentGeometry(comment, { + x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0) + / chartApi.chartEl.clientWidth), + y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0) + / chartApi.chartEl.clientHeight), + }); + } + } + } + async function endLineHere(line) { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -790,12 +861,11 @@ createApp({ } } if (isEditing(event.target)) return; - if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && chartApi) { - const direction = event.key === 'ArrowUp' ? 1 : -1; - if (chartApi.nudgeSelectedFlat(direction)) { - event.preventDefault(); - return; - } + if (event.key.startsWith('Arrow') && hasDrawingSelection.value) { + event.preventDefault(); + const multiplier = event.shiftKey ? 4 : 1; + nudgeQueue = nudgeQueue.then(() => nudgeSelectedDrawings(event.key, multiplier)); + return; } if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) { event.preventDefault(); diff --git a/static/chart.js b/static/chart.js index 8e636f0..1d35a9a 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1680,17 +1680,6 @@ class ConfluenceChart { if (level && this.onLineChange) this.onLineChange({ ...level }); } - nudgeSelectedFlat(direction) { - const level = this.levels.find(value => - value.id === this.selectedLineId && value.kind === 'manual' && ConfluenceChart.isFlat(value)); - if (!level) return false; - level.anchor_p = ConfluenceChart.snapToTick(level.anchor_p + direction * ConfluenceChart.TICK); - this.priceLines.get(level.id)?.line.applyOptions({ price: level.anchor_p }); - this.renderAnchorHandles(); - if (this.onLineChange) this.onLineChange({ ...level }); - return true; - } - showContextMenu(event) { const level = this.levels.find( value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value), diff --git a/static/index.html b/static/index.html index 213a6f5..69d13be 100644 --- a/static/index.html +++ b/static/index.html @@ -169,10 +169,9 @@
- -
diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs index 2cab514..fcf9b3a 100644 --- a/tests/e2e/comments.test.mjs +++ b/tests/e2e/comments.test.mjs @@ -49,10 +49,21 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as const anchor = await page.evaluate(t => { const c = window.__chart.comments.find(x => x.note === t); - return c ? c.anchor_t : null; + return c ? { t: c.anchor_t, p: c.anchor_p } : null; }, TEXT); 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.keyboard.press('ArrowUp'); + await page.keyboard.press('Shift+ArrowRight'); + await page.waitForFunction(([text, original]) => { + const c = window.__chart; + const comment = c.comments.find(value => value.note === text); + return comment?.anchor_p === original.p + 0.25 + && Math.round(c.indexAt(comment.anchor_t) - c.indexAt(original.t)) === 4; + }, [TEXT, anchor]); + // The bug: timeToCoordinate answers only for exact data points, so a 30m // bucket returned null on 15m and null was read as "off the left edge". for (const tf of ['15m', '1h', '30m']) { @@ -99,10 +110,17 @@ test('a floating comment holds its position while the chart scrolls', { timeout: await placeComment(page, box, 0.4, 0.3, { floating: true }); 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.keyboard.press('ArrowRight'); + await page.waitForFunction(([text, left]) => { + const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text); + return node && Math.round(node.getBoundingClientRect().left) === left + 4; + }, [TEXT, before.left]); await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false)); await page.waitForTimeout(1200); const after = await commentNode(page); - assert.equal(after.left, before.left, 'a floating comment moved with the chart'); + assert.equal(after.left, before.left + 4, 'a floating comment moved with the chart'); assertNoPageErrors(page, assert); }); }); diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 7e8e696..9e6bd16 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -425,11 +425,26 @@ test('dragging the selected line body translates both endpoints together', assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6, 'the endpoints moved by different price amounts'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Shift+ArrowRight'); + await page.waitForFunction(expected => { + const c = window.__chart; + const line = c.levels.find(level => level.id === expected.id); + return line?.anchor_p === expected.p + && Math.round(c.indexAt(line.anchor_t) - expected.index) === 4; + }, { id: moved.line.id, p: moved.line.anchor_p + 0.25, index: moved.anchorIndex }); + + const nudged = await page.evaluate(id => { + const c = window.__chart; + const line = c.levels.find(level => level.id === id); + return { anchor_t: line.anchor_t, anchor_p: line.anchor_p }; + }, moved.line.id); + await page.waitForFunction(async expected => { const response = await fetch('/api/drawings'); const drawing = (await response.json()).drawings.find(item => item.id === expected.id); return drawing?.anchor_t === expected.anchor_t && drawing?.anchor_p === expected.anchor_p; - }, { id: moved.line.id, anchor_t: moved.line.anchor_t, anchor_p: moved.line.anchor_p }); + }, { id: moved.line.id, ...nudged }); assertNoPageErrors(page, assert); }); });