key nudge for all drawing types
This commit is contained in:
parent
05e8aef40b
commit
54f4871757
6 changed files with 125 additions and 25 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue