trendlines should only show numbers until selected or hover
This commit is contained in:
parent
7590d53b13
commit
81045e2128
7 changed files with 205 additions and 51 deletions
|
|
@ -128,8 +128,8 @@ createApp({
|
|||
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
|
||||
const captureBusy = ref(false);
|
||||
const snap = ref(true);
|
||||
const selectedLine = ref(null);
|
||||
const selectedLines = ref([]);
|
||||
const selectedDrawing = ref(null);
|
||||
const selectedDrawings = ref([]);
|
||||
const alertPrice = ref(null);
|
||||
const alertNote = ref('');
|
||||
const levelColor = ref('#e0a34a');
|
||||
|
|
@ -159,8 +159,9 @@ createApp({
|
|||
}).format(new Date(dataReceivedAt.value));
|
||||
});
|
||||
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
|
||||
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
|
||||
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
|
||||
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)));
|
||||
|
||||
// One list for everything drawn. Lines come from the levels socket, which
|
||||
// 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');
|
||||
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
|
||||
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 = {
|
||||
url: location.href,
|
||||
timeframe: timeframe.value,
|
||||
|
|
@ -365,13 +367,16 @@ createApp({
|
|||
|
||||
function armTool(tool) {
|
||||
armedTool.value = armedTool.value === tool ? null : tool;
|
||||
selectedLine.value = null;
|
||||
selectedDrawing.value = null;
|
||||
selectedDrawings.value = [];
|
||||
chartApi.armTool(armedTool.value);
|
||||
}
|
||||
|
||||
function handleChartClick(param) {
|
||||
selectedLine.value = chartApi.hitTest(param);
|
||||
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
|
||||
const id = chartApi.hitTest(param);
|
||||
selectedDrawing.value = id;
|
||||
selectedDrawings.value = id ? [id] : [];
|
||||
if (id) chartApi.focusLine(id);
|
||||
}
|
||||
|
||||
async function handleToolComplete(result) {
|
||||
|
|
@ -439,6 +444,8 @@ createApp({
|
|||
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
|
||||
if (item.kind === 'comment') await refreshComments();
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -471,8 +478,8 @@ createApp({
|
|||
const saved = await response.json();
|
||||
levels.value = levels.value.filter(level => level.id !== temporaryId);
|
||||
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
|
||||
selectedLine.value = saved.id;
|
||||
selectedLines.value = [saved.id];
|
||||
selectedDrawing.value = saved.id;
|
||||
selectedDrawings.value = [saved.id];
|
||||
} catch (error) {
|
||||
levels.value = levels.value.filter(level => level.id !== temporaryId);
|
||||
console.error('Unable to save line', error);
|
||||
|
|
@ -498,8 +505,12 @@ createApp({
|
|||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean);
|
||||
await deleteLines(ids);
|
||||
const ids = selectedDrawings.value.length
|
||||
? 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) {
|
||||
|
|
@ -551,8 +562,8 @@ createApp({
|
|||
}
|
||||
const saved = await response.json();
|
||||
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
|
||||
selectedLine.value = saved.id;
|
||||
selectedLines.value = [saved.id];
|
||||
selectedDrawing.value = saved.id;
|
||||
selectedDrawings.value = [saved.id];
|
||||
syncVisibleLevels();
|
||||
}
|
||||
|
||||
|
|
@ -560,8 +571,8 @@ createApp({
|
|||
if (!ids.length) return;
|
||||
const deleting = new Set(ids);
|
||||
levels.value = levels.value.filter(level => !deleting.has(level.id));
|
||||
if (selectedLine.value && deleting.has(selectedLine.value)) selectedLine.value = null;
|
||||
selectedLines.value = selectedLines.value.filter(id => !deleting.has(id));
|
||||
if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null;
|
||||
selectedDrawings.value = selectedDrawings.value.filter(id => !deleting.has(id));
|
||||
syncVisibleLevels();
|
||||
const responses = [];
|
||||
for (const id of ids) {
|
||||
|
|
@ -572,25 +583,32 @@ createApp({
|
|||
});
|
||||
}
|
||||
|
||||
function selectLine(id) {
|
||||
selectedLine.value = id;
|
||||
selectedLines.value = [id];
|
||||
function focusDrawing(item) {
|
||||
selectedDrawing.value = item.id;
|
||||
selectedDrawings.value = [item.id];
|
||||
if (item.kind === 'comment') chartApi?.focusComment(item.id);
|
||||
else chartApi?.focusLine(item.id);
|
||||
}
|
||||
|
||||
function toggleLineSelection(id) {
|
||||
selectedLines.value = selectedLines.value.includes(id)
|
||||
? selectedLines.value.filter(value => value !== id)
|
||||
: [...selectedLines.value, id];
|
||||
selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null;
|
||||
function selectComment(comment) {
|
||||
selectedDrawing.value = comment.id;
|
||||
selectedDrawings.value = [comment.id];
|
||||
}
|
||||
|
||||
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() {
|
||||
selectedLines.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
|
||||
selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null;
|
||||
selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
|
||||
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
|
||||
}
|
||||
|
||||
async function deleteSelectedLines() {
|
||||
await deleteLines([...selectedLines.value]);
|
||||
await deleteLines([...selectedDrawings.value]);
|
||||
}
|
||||
|
||||
async function setArmed(line, armed) {
|
||||
|
|
@ -665,15 +683,15 @@ createApp({
|
|||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (hasLineSelection.value) {
|
||||
selectedLine.value = null;
|
||||
selectedLines.value = [];
|
||||
if (hasDrawingSelection.value) {
|
||||
selectedDrawing.value = null;
|
||||
selectedDrawings.value = [];
|
||||
event.preventDefault();
|
||||
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();
|
||||
deleteSelected();
|
||||
}
|
||||
|
|
@ -722,8 +740,8 @@ createApp({
|
|||
sendPrefs();
|
||||
}, { deep: true });
|
||||
|
||||
watch(selectedLine, id => {
|
||||
if (chartApi) chartApi.setSelectedLine(id);
|
||||
watch(selectedDrawing, id => {
|
||||
if (chartApi) chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
|
||||
});
|
||||
|
||||
watch(snap, value => {
|
||||
|
|
@ -744,7 +762,7 @@ createApp({
|
|||
chartApi.setLineDuplicateHandler(duplicateLine);
|
||||
chartApi.setLineDeleteHandler(deleteLine);
|
||||
chartApi.setSnap(snap.value);
|
||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
|
||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
await migrateLegacyToken();
|
||||
await refreshStatus();
|
||||
|
|
@ -759,6 +777,6 @@ createApp({
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class ConfluenceChart {
|
|||
this.selectedLineId = null;
|
||||
this.anchorHandles = [];
|
||||
this.lineHitTarget = null;
|
||||
this.lineFocus = null;
|
||||
this.lineFocusTimer = null;
|
||||
this.draggingLine = null;
|
||||
this.lineMoveListener = null;
|
||||
this.lineUpListener = null;
|
||||
|
|
@ -56,6 +58,7 @@ class ConfluenceChart {
|
|||
this.contextLabelFrame = null;
|
||||
this.onCommentToggle = null;
|
||||
this.onCommentMove = null;
|
||||
this.onCommentSelect = null;
|
||||
this.lastSnapDiagnostic = null;
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +209,10 @@ class ConfluenceChart {
|
|||
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
handles.classList.add('chart-handles');
|
||||
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.classList.add('chart-line-hit');
|
||||
this.lineHitTarget.setAttribute('hidden', '');
|
||||
|
|
@ -379,9 +386,10 @@ class ConfluenceChart {
|
|||
this.renderComments();
|
||||
}
|
||||
|
||||
setCommentHandlers({ onToggle, onMove }) {
|
||||
setCommentHandlers({ onToggle, onMove, onSelect }) {
|
||||
this.onCommentToggle = onToggle;
|
||||
this.onCommentMove = onMove;
|
||||
this.onCommentSelect = onSelect;
|
||||
}
|
||||
|
||||
// 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.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
|
||||
node.root.style.borderColor = comment.color;
|
||||
node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f');
|
||||
node.root.title = comment.note;
|
||||
|
||||
if (!comment.pinned) {
|
||||
|
|
@ -428,6 +437,7 @@ class ConfluenceChart {
|
|||
}
|
||||
for (const [id, node] of this.commentNodes) {
|
||||
if (!seen.has(id)) {
|
||||
clearTimeout(node.focusTimer);
|
||||
node.root.remove();
|
||||
this.commentNodes.delete(id);
|
||||
}
|
||||
|
|
@ -467,12 +477,17 @@ class ConfluenceChart {
|
|||
buildCommentNode(comment) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'chart-comment';
|
||||
const focus = document.createElement('span');
|
||||
focus.className = 'chart-comment-focus';
|
||||
focus.setAttribute('aria-hidden', 'true');
|
||||
const label = document.createElement('span');
|
||||
label.className = 'chart-comment-text';
|
||||
root.appendChild(label);
|
||||
const node = { root, label, comment };
|
||||
root.append(focus, label);
|
||||
const node = { root, focus, label, comment };
|
||||
root.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
this.focusComment(node.comment.id);
|
||||
if (this.onCommentSelect) this.onCommentSelect(node.comment);
|
||||
if (this.onCommentToggle) this.onCommentToggle(node.comment);
|
||||
});
|
||||
// Floating comments are dragged; pinned ones belong to their bar.
|
||||
|
|
@ -510,7 +525,7 @@ class ConfluenceChart {
|
|||
|
||||
seriesTitle(level) {
|
||||
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.label;
|
||||
|
|
@ -612,7 +627,7 @@ class ConfluenceChart {
|
|||
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
|
||||
priceLineVisible: false,
|
||||
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.
|
||||
autoscaleInfoProvider: () => null,
|
||||
};
|
||||
|
|
@ -1119,13 +1134,55 @@ class ConfluenceChart {
|
|||
setSelectedLine(id) {
|
||||
this.selectedLineId = id;
|
||||
this.hideContextMenu();
|
||||
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')) {
|
||||
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.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) {
|
||||
if (start.t === end.t) return;
|
||||
const x1 = this.chart.timeScale().timeToCoordinate(start.t);
|
||||
|
|
@ -1161,8 +1218,12 @@ class ConfluenceChart {
|
|||
let best = null;
|
||||
const visible = this.visibleBarIndexes();
|
||||
if (!visible) return null;
|
||||
// Flat levels draw as price lines, which have no series geometry to hit —
|
||||
// they are selected and removed from the sidebar list instead.
|
||||
for (const level of this.levels.filter(value =>
|
||||
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))) {
|
||||
const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
|
||||
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.plotResizeObserver) this.plotResizeObserver.disconnect();
|
||||
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.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
|
||||
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
<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-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">
|
||||
{{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
|
||||
</button>
|
||||
|
|
@ -129,11 +129,11 @@
|
|||
<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: selectedLines.includes(item.id)}"
|
||||
@click="item.kind !== 'comment' && toggleLineSelection(item.id)">
|
||||
:class="{selected: selectedDrawings.includes(item.id)}"
|
||||
@click="focusDrawing(item)">
|
||||
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
|
||||
:checked="selectedLines.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
||||
@click.stop @change="toggleLineSelection(item.id)">
|
||||
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
||||
@click.stop @change="toggleDrawingSelection(item.id)">
|
||||
<span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span>
|
||||
|
||||
<div class="drawing-content">
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
.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); }
|
||||
#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; }
|
||||
.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; }
|
||||
|
|
@ -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;
|
||||
font-size:9px; white-space:nowrap; }
|
||||
.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. */
|
||||
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
|
||||
.chart-comment.off-left::before { content:'◀ '; }
|
||||
|
|
|
|||
|
|
@ -70,9 +70,19 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
|
|||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.5, 0.35);
|
||||
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
|
||||
// comments are on the chart too and `.chart-comment` matches them first.
|
||||
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);
|
||||
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
|
||||
assertNoPageErrors(page, assert);
|
||||
|
|
|
|||
|
|
@ -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.equal(await row.locator('.drawing-state .fa-bell').count(), 1,
|
||||
'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();
|
||||
assert.equal(await row.locator('.color-popover>button[aria-label^="Use color"]').count(), 16,
|
||||
'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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.sort((a, b) => b.number - a.number)[0];
|
||||
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) =>
|
||||
Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0);
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in a new issue