key nudge for all drawing types

This commit is contained in:
Chris Amow 2026-08-13 19:02:49 -05:00
parent 05e8aef40b
commit 54f4871757
6 changed files with 125 additions and 25 deletions

View file

@ -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 tests pin both approach directions and persistence; the browser test pins list
editing, snapped handle movement and a one-tick keyboard nudge. 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 ### 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 Trendlines project into the whitespace right of the last candle, but the axis

View file

@ -171,6 +171,7 @@ createApp({
let socket = null; let socket = null;
let retryDelay = 2000; let retryDelay = 2000;
let timer = null; let timer = null;
let nudgeQueue = Promise.resolve();
const barAge = computed(() => { const barAge = computed(() => {
if (!status.value.last_bar_t) return '—'; if (!status.value.last_bar_t) return '—';
@ -185,8 +186,9 @@ createApp({
}); });
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null); const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0 const allManualSelected = computed(() => manualLines.value.length + comments.value.length > 0
&& manualLines.value.every(line => selectedDrawings.value.includes(line.id))); && [...manualLines.value, ...comments.value]
.every(drawing => selectedDrawings.value.includes(drawing.id)));
// One list for everything drawn. Lines come from the levels socket, which // One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint. // keeps them live; comments come from the drawings endpoint.
@ -695,7 +697,7 @@ createApp({
} }
function toggleSelectAll() { 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; selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
} }
@ -746,6 +748,75 @@ createApp({
syncVisibleLevels(); 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) { async function endLineHere(line) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
@ -790,13 +861,12 @@ createApp({
} }
} }
if (isEditing(event.target)) return; if (isEditing(event.target)) return;
if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && chartApi) { if (event.key.startsWith('Arrow') && hasDrawingSelection.value) {
const direction = event.key === 'ArrowUp' ? 1 : -1;
if (chartApi.nudgeSelectedFlat(direction)) {
event.preventDefault(); event.preventDefault();
const multiplier = event.shiftKey ? 4 : 1;
nudgeQueue = nudgeQueue.then(() => nudgeSelectedDrawings(event.key, multiplier));
return; return;
} }
}
if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) { if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) {
event.preventDefault(); event.preventDefault();
deleteSelected(); deleteSelected();

View file

@ -1680,17 +1680,6 @@ class ConfluenceChart {
if (level && this.onLineChange) this.onLineChange({ ...level }); 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) { showContextMenu(event) {
const level = this.levels.find( const level = this.levels.find(
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value), value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),

View file

@ -169,10 +169,9 @@
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row" <div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedDrawings.includes(item.id)}" :class="{selected: selectedDrawings.includes(item.id)}"
@click="focusDrawing(item)"> @click="focusDrawing(item)">
<input v-if="item.line" class="line-select" type="checkbox" <input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`" :checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)"> @click.stop @change="toggleDrawingSelection(item.id)">
<span v-else class="line-select drawing-icon"><i :class="item.kind === 'symbol' ? `fa-solid fa-${item.comment.icon}` : 'fa-solid fa-note-sticky'"></i></span>
<div class="drawing-content"> <div class="drawing-content">
<div class="drawing-primary"> <div class="drawing-primary">

View file

@ -49,10 +49,21 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as
const anchor = await page.evaluate(t => { const anchor = await page.evaluate(t => {
const c = window.__chart.comments.find(x => x.note === 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); }, TEXT);
assert.ok(anchor, 'the comment was not created'); 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 // 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". // bucket returned null on 15m and null was read as "off the left edge".
for (const tf of ['15m', '1h', '30m']) { 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 }); await placeComment(page, box, 0.4, 0.3, { floating: true });
const before = await commentNode(page); const before = await commentNode(page);
assert.ok(before, 'the floating comment was not created'); 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.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
const after = await commentNode(page); 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); assertNoPageErrors(page, assert);
}); });
}); });

View file

@ -425,11 +425,26 @@ test('dragging the selected line body translates both endpoints together',
assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6, assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6,
'the endpoints moved by different price amounts'); '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 => { await page.waitForFunction(async expected => {
const response = await fetch('/api/drawings'); const response = await fetch('/api/drawings');
const drawing = (await response.json()).drawings.find(item => item.id === expected.id); 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; 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); assertNoPageErrors(page, assert);
}); });
}); });