trendlines should only show numbers until selected or hover

This commit is contained in:
Chris Amow 2026-08-11 18:59:12 -05:00
parent 7590d53b13
commit 81045e2128
7 changed files with 205 additions and 51 deletions

View file

@ -128,8 +128,8 @@ createApp({
const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false); const captureBusy = ref(false);
const snap = ref(true); const snap = ref(true);
const selectedLine = ref(null); const selectedDrawing = ref(null);
const selectedLines = ref([]); const selectedDrawings = ref([]);
const alertPrice = ref(null); const alertPrice = ref(null);
const alertNote = ref(''); const alertNote = ref('');
const levelColor = ref('#e0a34a'); const levelColor = ref('#e0a34a');
@ -159,8 +159,9 @@ createApp({
}).format(new Date(dataReceivedAt.value)); }).format(new Date(dataReceivedAt.value));
}); });
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); const allManualSelected = computed(() => manualLines.value.length > 0
&& manualLines.value.every(line => selectedDrawings.value.includes(line.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.
@ -327,7 +328,8 @@ createApp({
if (!image) throw new Error('Browser did not produce a PNG'); if (!image) throw new Error('Browser did not produce a PNG');
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({})); const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
const bars = chartApi?.bars || []; const bars = chartApi?.bars || [];
const selected = levels.value.find(level => level.id === selectedLine.value) || null; const item = drawings.value.find(drawing => drawing.id === selectedDrawing.value);
const selected = item?.line || item?.comment || null;
const metadata = { const metadata = {
url: location.href, url: location.href,
timeframe: timeframe.value, timeframe: timeframe.value,
@ -365,13 +367,16 @@ createApp({
function armTool(tool) { function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool; armedTool.value = armedTool.value === tool ? null : tool;
selectedLine.value = null; selectedDrawing.value = null;
selectedDrawings.value = [];
chartApi.armTool(armedTool.value); chartApi.armTool(armedTool.value);
} }
function handleChartClick(param) { function handleChartClick(param) {
selectedLine.value = chartApi.hitTest(param); const id = chartApi.hitTest(param);
selectedLines.value = selectedLine.value ? [selectedLine.value] : []; selectedDrawing.value = id;
selectedDrawings.value = id ? [id] : [];
if (id) chartApi.focusLine(id);
} }
async function handleToolComplete(result) { async function handleToolComplete(result) {
@ -439,6 +444,8 @@ createApp({
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' }); await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.kind === 'comment') await refreshComments(); if (item.kind === 'comment') await refreshComments();
else levels.value = levels.value.filter(level => level.id !== item.id); else levels.value = levels.value.filter(level => level.id !== item.id);
if (selectedDrawing.value === item.id) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id);
syncVisibleLevels(); syncVisibleLevels();
} }
@ -471,8 +478,8 @@ createApp({
const saved = await response.json(); const saved = await response.json();
levels.value = levels.value.filter(level => level.id !== temporaryId); levels.value = levels.value.filter(level => level.id !== temporaryId);
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved); if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
selectedLine.value = saved.id; selectedDrawing.value = saved.id;
selectedLines.value = [saved.id]; selectedDrawings.value = [saved.id];
} catch (error) { } catch (error) {
levels.value = levels.value.filter(level => level.id !== temporaryId); levels.value = levels.value.filter(level => level.id !== temporaryId);
console.error('Unable to save line', error); console.error('Unable to save line', error);
@ -498,8 +505,12 @@ createApp({
} }
async function deleteSelected() { async function deleteSelected() {
const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean); const ids = selectedDrawings.value.length
await deleteLines(ids); ? selectedDrawings.value : [selectedDrawing.value].filter(Boolean);
for (const id of ids) {
const item = drawings.value.find(drawing => drawing.id === id);
if (item) await deleteDrawing(item);
}
} }
async function deleteLine(id) { async function deleteLine(id) {
@ -551,8 +562,8 @@ createApp({
} }
const saved = await response.json(); const saved = await response.json();
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
selectedLine.value = saved.id; selectedDrawing.value = saved.id;
selectedLines.value = [saved.id]; selectedDrawings.value = [saved.id];
syncVisibleLevels(); syncVisibleLevels();
} }
@ -560,8 +571,8 @@ createApp({
if (!ids.length) return; if (!ids.length) return;
const deleting = new Set(ids); const deleting = new Set(ids);
levels.value = levels.value.filter(level => !deleting.has(level.id)); levels.value = levels.value.filter(level => !deleting.has(level.id));
if (selectedLine.value && deleting.has(selectedLine.value)) selectedLine.value = null; if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null;
selectedLines.value = selectedLines.value.filter(id => !deleting.has(id)); selectedDrawings.value = selectedDrawings.value.filter(id => !deleting.has(id));
syncVisibleLevels(); syncVisibleLevels();
const responses = []; const responses = [];
for (const id of ids) { for (const id of ids) {
@ -572,25 +583,32 @@ createApp({
}); });
} }
function selectLine(id) { function focusDrawing(item) {
selectedLine.value = id; selectedDrawing.value = item.id;
selectedLines.value = [id]; selectedDrawings.value = [item.id];
if (item.kind === 'comment') chartApi?.focusComment(item.id);
else chartApi?.focusLine(item.id);
} }
function toggleLineSelection(id) { function selectComment(comment) {
selectedLines.value = selectedLines.value.includes(id) selectedDrawing.value = comment.id;
? selectedLines.value.filter(value => value !== id) selectedDrawings.value = [comment.id];
: [...selectedLines.value, id]; }
selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null;
function toggleDrawingSelection(id) {
selectedDrawings.value = selectedDrawings.value.includes(id)
? selectedDrawings.value.filter(value => value !== id)
: [...selectedDrawings.value, id];
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
} }
function toggleSelectAll() { function toggleSelectAll() {
selectedLines.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id); selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
} }
async function deleteSelectedLines() { async function deleteSelectedLines() {
await deleteLines([...selectedLines.value]); await deleteLines([...selectedDrawings.value]);
} }
async function setArmed(line, armed) { async function setArmed(line, armed) {
@ -665,15 +683,15 @@ createApp({
event.preventDefault(); event.preventDefault();
return; return;
} }
if (hasLineSelection.value) { if (hasDrawingSelection.value) {
selectedLine.value = null; selectedDrawing.value = null;
selectedLines.value = []; selectedDrawings.value = [];
event.preventDefault(); event.preventDefault();
return; return;
} }
} }
if (isEditing(event.target)) return; if (isEditing(event.target)) return;
if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.value) { if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) {
event.preventDefault(); event.preventDefault();
deleteSelected(); deleteSelected();
} }
@ -722,8 +740,8 @@ createApp({
sendPrefs(); sendPrefs();
}, { deep: true }); }, { deep: true });
watch(selectedLine, id => { watch(selectedDrawing, id => {
if (chartApi) chartApi.setSelectedLine(id); if (chartApi) chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
}); });
watch(snap, value => { watch(snap, value => {
@ -744,7 +762,7 @@ createApp({
chartApi.setLineDuplicateHandler(duplicateLine); chartApi.setLineDuplicateHandler(duplicateLine);
chartApi.setLineDeleteHandler(deleteLine); chartApi.setLineDeleteHandler(deleteLine);
chartApi.setSnap(snap.value); chartApi.setSnap(snap.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment }); chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
await migrateLegacyToken(); await migrateLegacyToken();
await refreshStatus(); await refreshStatus();
@ -759,6 +777,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -18,6 +18,8 @@ class ConfluenceChart {
this.selectedLineId = null; this.selectedLineId = null;
this.anchorHandles = []; this.anchorHandles = [];
this.lineHitTarget = null; this.lineHitTarget = null;
this.lineFocus = null;
this.lineFocusTimer = null;
this.draggingLine = null; this.draggingLine = null;
this.lineMoveListener = null; this.lineMoveListener = null;
this.lineUpListener = null; this.lineUpListener = null;
@ -56,6 +58,7 @@ class ConfluenceChart {
this.contextLabelFrame = null; this.contextLabelFrame = null;
this.onCommentToggle = null; this.onCommentToggle = null;
this.onCommentMove = null; this.onCommentMove = null;
this.onCommentSelect = null;
this.lastSnapDiagnostic = null; this.lastSnapDiagnostic = null;
} }
@ -206,6 +209,10 @@ class ConfluenceChart {
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
handles.classList.add('chart-handles'); handles.classList.add('chart-handles');
handles.setAttribute('aria-hidden', 'true'); handles.setAttribute('aria-hidden', 'true');
this.lineFocus = document.createElementNS('http://www.w3.org/2000/svg', 'line');
this.lineFocus.classList.add('chart-line-focus');
this.lineFocus.setAttribute('hidden', '');
handles.appendChild(this.lineFocus);
this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line'); this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line');
this.lineHitTarget.classList.add('chart-line-hit'); this.lineHitTarget.classList.add('chart-line-hit');
this.lineHitTarget.setAttribute('hidden', ''); this.lineHitTarget.setAttribute('hidden', '');
@ -379,9 +386,10 @@ class ConfluenceChart {
this.renderComments(); this.renderComments();
} }
setCommentHandlers({ onToggle, onMove }) { setCommentHandlers({ onToggle, onMove, onSelect }) {
this.onCommentToggle = onToggle; this.onCommentToggle = onToggle;
this.onCommentMove = onMove; this.onCommentMove = onMove;
this.onCommentSelect = onSelect;
} }
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse, // Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
@ -404,6 +412,7 @@ class ConfluenceChart {
node.root.classList.toggle('floating', !comment.pinned); node.root.classList.toggle('floating', !comment.pinned);
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note; node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
node.root.style.borderColor = comment.color; node.root.style.borderColor = comment.color;
node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f');
node.root.title = comment.note; node.root.title = comment.note;
if (!comment.pinned) { if (!comment.pinned) {
@ -428,6 +437,7 @@ class ConfluenceChart {
} }
for (const [id, node] of this.commentNodes) { for (const [id, node] of this.commentNodes) {
if (!seen.has(id)) { if (!seen.has(id)) {
clearTimeout(node.focusTimer);
node.root.remove(); node.root.remove();
this.commentNodes.delete(id); this.commentNodes.delete(id);
} }
@ -467,12 +477,17 @@ class ConfluenceChart {
buildCommentNode(comment) { buildCommentNode(comment) {
const root = document.createElement('div'); const root = document.createElement('div');
root.className = 'chart-comment'; root.className = 'chart-comment';
const focus = document.createElement('span');
focus.className = 'chart-comment-focus';
focus.setAttribute('aria-hidden', 'true');
const label = document.createElement('span'); const label = document.createElement('span');
label.className = 'chart-comment-text'; label.className = 'chart-comment-text';
root.appendChild(label); root.append(focus, label);
const node = { root, label, comment }; const node = { root, focus, label, comment };
root.addEventListener('click', event => { root.addEventListener('click', event => {
event.stopPropagation(); event.stopPropagation();
this.focusComment(node.comment.id);
if (this.onCommentSelect) this.onCommentSelect(node.comment);
if (this.onCommentToggle) this.onCommentToggle(node.comment); if (this.onCommentToggle) this.onCommentToggle(node.comment);
}); });
// Floating comments are dragged; pinned ones belong to their bar. // Floating comments are dragged; pinned ones belong to their bar.
@ -510,7 +525,7 @@ class ConfluenceChart {
seriesTitle(level) { seriesTitle(level) {
if (ConfluenceChart.isContextLevel(level)) return ''; if (ConfluenceChart.isContextLevel(level)) return '';
if (level.kind === 'manual' && !ConfluenceChart.isFlat(level)) { if (level.kind === 'manual') {
return level.id === this.selectedLineId ? level.label : `#${level.number}`; return level.id === this.selectedLineId ? level.label : `#${level.number}`;
} }
return level.label; return level.label;
@ -612,7 +627,7 @@ class ConfluenceChart {
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null, lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null,
title: ConfluenceChart.isContextLevel(level) ? '' : level.label, title: this.seriesTitle(level),
// Overlays outside the visible price range must not flatten the candles. // Overlays outside the visible price range must not flatten the candles.
autoscaleInfoProvider: () => null, autoscaleInfoProvider: () => null,
}; };
@ -1119,13 +1134,55 @@ class ConfluenceChart {
setSelectedLine(id) { setSelectedLine(id) {
this.selectedLineId = id; this.selectedLineId = id;
this.hideContextMenu(); this.hideContextMenu();
for (const level of this.levels.filter(value => for (const level of this.levels.filter(value => value.kind === 'manual')) {
value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { if (ConfluenceChart.isFlat(level)) {
this.priceLines.get(level.id)?.line.applyOptions({ title: this.seriesTitle(level) });
} else {
this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) }); this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) });
} }
}
this.renderAnchorHandles(); this.renderAnchorHandles();
} }
focusLine(id) {
this.setSelectedLine(id);
const level = this.levels.find(value => value.id === id && value.kind === 'manual');
if (!level) return;
if (ConfluenceChart.isFlat(level)) {
const y = this.candles.priceToCoordinate(level.anchor_p);
if (y == null) return;
this.lineFocus.setAttribute('x1', 0);
this.lineFocus.setAttribute('y1', y);
this.lineFocus.setAttribute('x2', this.overlayLayer.getBoundingClientRect().width);
this.lineFocus.setAttribute('y2', y);
} else {
if (this.lineHitTarget.hasAttribute('hidden')) return;
for (const attribute of ['x1', 'y1', 'x2', 'y2']) {
this.lineFocus.setAttribute(attribute, this.lineHitTarget.getAttribute(attribute));
}
}
this.lineFocus.setAttribute('stroke', ConfluenceChart.levelColor(level));
const lineWidth = level.line_width || 2;
this.lineFocus.style.setProperty('--line-focus-start-width', `${lineWidth + 10}px`);
this.lineFocus.style.setProperty('--line-focus-width', `${lineWidth}px`);
this.lineFocus.removeAttribute('hidden');
this.lineFocus.style.animation = 'none';
void this.lineFocus.getBoundingClientRect();
this.lineFocus.style.animation = '';
clearTimeout(this.lineFocusTimer);
this.lineFocusTimer = setTimeout(() => this.lineFocus.setAttribute('hidden', ''), 800);
}
focusComment(id) {
const node = this.commentNodes.get(id);
if (!node) return;
clearTimeout(node.focusTimer);
node.root.classList.remove('focus');
void node.root.getBoundingClientRect();
node.root.classList.add('focus');
node.focusTimer = setTimeout(() => node.root.classList.remove('focus'), 800);
}
setLinePreview(start, end, color, lineWidth) { setLinePreview(start, end, color, lineWidth) {
if (start.t === end.t) return; if (start.t === end.t) return;
const x1 = this.chart.timeScale().timeToCoordinate(start.t); const x1 = this.chart.timeScale().timeToCoordinate(start.t);
@ -1161,8 +1218,12 @@ class ConfluenceChart {
let best = null; let best = null;
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
if (!visible) return null; if (!visible) return null;
// Flat levels draw as price lines, which have no series geometry to hit — for (const level of this.levels.filter(value =>
// they are selected and removed from the sidebar list instead. value.kind === 'manual' && ConfluenceChart.isFlat(value) && !value.hidden)) {
const y = this.candles.priceToCoordinate(level.anchor_p);
const distance = y == null ? Infinity : Math.abs(y - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
}
for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) {
const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
@ -1521,6 +1582,8 @@ class ConfluenceChart {
if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); if (this.plotResizeObserver) this.plotResizeObserver.disconnect();
if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame); if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame);
clearTimeout(this.lineFocusTimer);
for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer);
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);

View file

@ -31,7 +31,7 @@
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span> <span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span> <span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span> <span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span> <span v-else-if="selectedDrawing" class="arm-hint">Drawing selected — Delete removes it</span>
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic"> <button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
{{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }} {{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
</button> </button>
@ -129,11 +129,11 @@
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</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" <div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedLines.includes(item.id)}" :class="{selected: selectedDrawings.includes(item.id)}"
@click="item.kind !== 'comment' && toggleLineSelection(item.id)"> @click="focusDrawing(item)">
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox" <input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
:checked="selectedLines.includes(item.id)" :aria-label="`Select drawing ${item.number}`" :checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleLineSelection(item.id)"> @click.stop @change="toggleDrawingSelection(item.id)">
<span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span> <span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span>
<div class="drawing-content"> <div class="drawing-content">

View file

@ -13,7 +13,8 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.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; } .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; } 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; }
@ -65,6 +66,12 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
align-items:center; justify-content:center; border-radius:50%; border-left-width:1px; align-items:center; justify-content:center; border-radius:50%; border-left-width:1px;
font-size:9px; white-space:nowrap; } font-size:9px; white-space:nowrap; }
.chart-comment.floating { border-style:dashed; cursor:grab; } .chart-comment.floating { border-style:dashed; cursor:grab; }
.chart-comment-focus { position:absolute; inset:-1px; border:1px solid var(--comment-focus-color); border-radius:inherit; opacity:0; pointer-events:none; }
.chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; }
@keyframes comment-focus {
0% { inset:-11px; border-width:11px; opacity:.65; }
100% { inset:-1px; border-width:1px; opacity:0; }
}
/* Parked on the edge it scrolled off, pointing the way back to it. */ /* Parked on the edge it scrolled off, pointing the way back to it. */
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; } .chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
.chart-comment.off-left::before { content:'◀ '; } .chart-comment.off-left::before { content:'◀ '; }

View file

@ -70,9 +70,19 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
const box = await chartBox(page); const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.35); await placeComment(page, box, 0.5, 0.35);
assert.equal((await commentNode(page)).collapsed, false); assert.equal((await commentNode(page)).collapsed, false);
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
const row = page.locator('.trendline-row');
await row.locator('.drawing-secondary>span').click();
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');
await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection');
// By title, not by class: the dev store is shared, so other people's // By title, not by class: the dev store is shared, so other people's
// comments are on the chart too and `.chart-comment` matches them first. // comments are on the chart too and `.chart-comment` matches them first.
await page.click(`.chart-comment[title="${TEXT}"]`); await page.click(`.chart-comment[title="${TEXT}"]`);
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
'clicking the chart comment did not select its drawing row');
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it'); assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);

View file

@ -40,6 +40,12 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`); assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`);
assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1, assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1,
'the armed state is not represented by a bell'); 'the armed state is not represented by a bell');
const checkbox = row.locator('.line-select');
if (await checkbox.isChecked()) await checkbox.click();
await row.locator('.drawing-secondary>span').click();
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');
await row.locator('.color-picker>summary').click(); await row.locator('.color-picker>summary').click();
assert.equal(await row.locator('.color-popover>button[aria-label^="Use color"]').count(), 16, assert.equal(await row.locator('.color-popover>button[aria-label^="Use color"]').count(), 16,
'the preset palette does not contain 16 colors'); 'the preset palette does not contain 16 colors');
@ -75,3 +81,50 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });
test('a price level selects and focuses in both directions', { timeout: 180000 }, async () => {
await withChart(async page => {
const label = `e2e level focus ${Date.now()}`;
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
await armTool(page, 'Price level');
await page.fill('input[aria-label="Level label"]', label);
await page.fill('input[aria-label="Level price"]', String(price));
await page.locator('.price-row button[type="submit"]').click();
await page.fill('input[aria-label="Filter drawings by text"]', label);
const row = page.locator('.trendline-row');
await row.waitFor();
const id = await page.evaluate(name =>
window.__chart.levels.find(level => level.label === name)?.id || null, label);
assert.ok(id, 'the price level was not created');
const number = await page.evaluate(levelId =>
window.__chart.levels.find(level => level.id === levelId).number, id);
assert.equal(await page.evaluate(levelId =>
window.__chart.priceLines.get(levelId).line.options().title, id), `#${number}`,
'an unselected price level exposed its full name on the price axis');
await row.locator('.drawing-secondary>span').click();
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,
'a selected price level did not show its full name on the price axis');
const focus = page.locator('.chart-line-focus');
assert.equal(await focus.getAttribute('hidden'), null, 'the selected price level was not highlighted');
assert.equal(await focus.evaluate(node => getComputedStyle(node).animationName), 'line-focus',
'the price-level focus animation did not run');
assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'),
'the price-level highlight was not horizontal');
await page.keyboard.press('Escape');
const point = await page.evaluate(levelId => {
const chart = window.__chart;
const level = chart.levels.find(value => value.id === levelId);
const plot = chart.plotCanvas().getBoundingClientRect();
return { x: plot.left + plot.width / 2, y: plot.top + chart.candles.priceToCoordinate(level.anchor_p) };
}, id);
await page.mouse.click(point.x, point.y);
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
'clicking the chart price level did not select its drawing row');
assertNoPageErrors(page, assert);
});
});

View file

@ -333,6 +333,9 @@ test('the line context menu duplicates by ten bars and deletes through the norma
level.kind === 'manual' && level.number > originalLine.number) level.kind === 'manual' && level.number > originalLine.number)
.sort((a, b) => b.number - a.number)[0]; .sort((a, b) => b.number - a.number)[0];
const original = c.levels.find(level => level.id === originalLine.id); const original = c.levels.find(level => level.id === originalLine.id);
// Socket snapshots and style edits resync every series. That resync
// must not restore full names to unselected price-axis labels.
c.syncLevels(c.levels);
const nearestIndex = time => c.bars.reduce((nearest, bar, index) => const nearestIndex = time => c.bars.reduce((nearest, bar, index) =>
Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0); Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0);
return { return {