symbol improvements

This commit is contained in:
Chris Amow 2026-08-11 19:51:39 -05:00
parent 0bca5d3bb7
commit e957991242
9 changed files with 324 additions and 44 deletions

View file

@ -32,6 +32,7 @@ class ManualLine:
x: float = 0.72
y: float = 0.12
collapsed: bool = False
icon: str = ""
@property
def drawing_kind(self) -> str:
@ -47,7 +48,7 @@ class ManualLine:
@property
def is_comment(self) -> bool:
return self.drawing_kind == "comment"
return self.drawing_kind in {"comment", "symbol"}
@property
def horizontal(self) -> bool:
@ -114,6 +115,7 @@ class ManualLine:
x=float(value.get("x", 0.72)),
y=float(value.get("y", 0.12)),
collapsed=bool(value.get("collapsed", False)),
icon=str(value.get("icon", "")),
)

View file

@ -4,6 +4,7 @@ import json
import logging
import time
import uuid
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@ -219,6 +220,11 @@ class CommentCreate(BaseModel):
y: float = Field(0.12, ge=0.0, le=1.0)
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
tf: Timeframe = Timeframe.M1
icon: Literal[
"arrow-up", "arrow-down", "face-smile", "hand-point-right",
"skull", "face-laugh-squint", "champagne-glasses",
"arrow-left", "arrow-right", "hand", "right-to-bracket", "play",
] | None = None
@router.post("/comments", status_code=201)
@ -241,7 +247,8 @@ def create_comment(request: Request, payload: CommentCreate):
created_at=now,
note=payload.text,
color=payload.color,
kind="comment",
kind="symbol" if payload.icon else "comment",
icon=payload.icon or "",
pinned=payload.pinned,
x=payload.x,
y=payload.y,

View file

@ -40,7 +40,7 @@ improves desktop click-click placement.
### P0: Mobile tool rail next to the chart
On mobile, keep Trendline, Level, Comment, active-tool state, and Cancel in a
On mobile, keep Trendline, Level, Comment, Symbol, active-tool state, and Cancel in a
compact sticky rail directly below the chart. Open tool configuration in an
expandable panel or sheet. Leave Layers and the complete Drawings manager below.
@ -98,13 +98,14 @@ matter.
### P1: Separate comment actions
Do not make the whole comment simultaneously mean collapse, open, and drag.
Use tap to select/open, an explicit collapse control, and a dedicated drag
handle for floating comments. Expose Edit text, Pin/Float, Collapse, and Delete
through the selected-drawing actions.
Desktop now separates these actions: the body selects, the left-side control
collapses, and a dedicated grip moves floating comments and pinned symbols.
Carry that interaction to touch, with suitably enlarged invisible hit regions.
Expose Edit text, Pin/Float, Collapse, and Delete through the selected-drawing
actions.
Complexity: medium. Desktop click-to-collapse can remain initially if changing
it would be disruptive.
Complexity: medium. The behavior exists; mobile still needs touch-sized targets
and gesture verification.
### P1: Keyboard-safe mobile layout

View file

@ -120,6 +120,9 @@ createApp({
const drawSide = ref('support');
const commentText = ref('');
const commentFloat = ref(false);
const selectedSymbol = ref('arrow-up');
const symbolColor = ref('#65b7cf');
const symbolPanelOpen = ref(false);
// Comments are not levels, so they never arrive over the levels socket and
// are fetched and refreshed explicitly.
const comments = ref([]);
@ -148,6 +151,20 @@ createApp({
'#9fb0c4': 'Slate', '#746c60': 'Taupe', '#2c2924': 'Charcoal', '#c8992f': 'Ochre',
};
const drawingColorName = color => drawingColorNames[color] || color;
const symbolChoices = [
{ icon: 'arrow-up', name: 'Up arrow' },
{ icon: 'arrow-down', name: 'Down arrow' },
{ icon: 'face-smile', name: 'Smiley' },
{ icon: 'hand-point-right', name: 'Pointing finger' },
{ icon: 'skull', name: 'Skull' },
{ icon: 'face-laugh-squint', name: 'Laughing face' },
{ icon: 'champagne-glasses', name: 'Celebrate' },
{ icon: 'arrow-left', name: 'Left arrow' },
{ icon: 'arrow-right', name: 'Right arrow' },
{ icon: 'hand', name: 'Stop' },
{ icon: 'right-to-bracket', name: 'Enter trade' },
{ icon: 'play', name: 'Go' },
];
const now = ref(Date.now());
let chartApi = null;
let socket = null;
@ -183,7 +200,7 @@ createApp({
const notes = comments.value.map(comment => ({
id: comment.id,
number: comment.number,
kind: 'comment',
kind: comment.kind === 'symbol' ? 'symbol' : 'comment',
label: comment.note || '',
comment,
}));
@ -374,6 +391,7 @@ createApp({
function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool;
if (tool !== 'symbol') symbolPanelOpen.value = false;
selectedDrawing.value = null;
selectedDrawings.value = [];
chartApi.armTool(armedTool.value);
@ -387,13 +405,18 @@ createApp({
}
async function handleToolComplete(result) {
// One placement per arming, so a tool cannot keep firing on stray drags.
// Placement is one-shot even though the symbol palette remains open. The
// next gesture should select or adjust the mark, not stamp another one.
armedTool.value = null;
chartApi.armTool(null);
if (result.tool === 'comment') {
await createComment(result);
return;
}
if (result.tool === 'symbol') {
await createSymbol(result);
return;
}
if (result.tool === 'level') {
alertPrice.value = result.price;
await addPriceAlert();
@ -406,7 +429,7 @@ createApp({
const response = await apiFetch('/api/drawings');
if (!response.ok) return;
const payload = await response.json();
comments.value = (payload.drawings || []).filter(item => item.kind === 'comment');
comments.value = (payload.drawings || []).filter(item => ['comment', 'symbol'].includes(item.kind));
syncComments();
}
@ -435,6 +458,57 @@ createApp({
await refreshComments();
}
async function createSymbol(result, icon = selectedSymbol.value) {
const choice = symbolChoices.find(symbol => symbol.icon === icon);
if (!choice) return;
const response = await apiFetch('/api/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: choice.name, icon, color: symbolColor.value, pinned: true,
anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y,
}),
});
if (!response.ok) {
console.error('Unable to save symbol', response.status);
return;
}
await refreshComments();
}
function chooseSymbol(symbol) {
selectedSymbol.value = symbol.icon;
armedTool.value = 'symbol';
chartApi.armTool('symbol');
}
function toggleSymbolPanel() {
symbolPanelOpen.value = !symbolPanelOpen.value;
if (symbolPanelOpen.value) {
armedTool.value = 'symbol';
chartApi.armTool('symbol');
} else if (armedTool.value === 'symbol') {
armedTool.value = null;
chartApi.armTool(null);
}
}
function startSymbolDrag(event, symbol) {
selectedSymbol.value = symbol.icon;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/x-chart-symbol', symbol.icon);
}
async function dropSymbol(event) {
const icon = event.dataTransfer.getData('text/x-chart-symbol');
const result = chartApi?.placementAtClient(event.clientX, event.clientY);
if (!icon || !result) return;
await createSymbol(result, icon);
if (armedTool.value === 'symbol') {
armedTool.value = null;
chartApi.armTool(null);
}
}
async function patchComment(comment, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
@ -444,12 +518,12 @@ createApp({
}
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
const moveComment = (comment, x, y) => patchComment(comment, { x, y });
const moveComment = (comment, changes) => patchComment(comment, changes);
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
async function deleteDrawing(item) {
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.kind === 'comment') await refreshComments();
if (item.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);
@ -593,7 +667,7 @@ createApp({
function focusDrawing(item) {
selectedDrawing.value = item.id;
selectedDrawings.value = [item.id];
if (item.kind === 'comment') chartApi?.focusComment(item.id);
if (item.comment) chartApi?.focusComment(item.id);
else chartApi?.focusLine(item.id);
}
@ -635,7 +709,7 @@ createApp({
const saved = await response.json();
// A comment is not a level and must not be pushed into the level list,
// where it would be drawn as a line.
if (line.kind === 'comment') { await refreshComments(); return; }
if (['comment', 'symbol'].includes(line.kind)) { await refreshComments(); return; }
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels();
}
@ -748,7 +822,10 @@ createApp({
}, { deep: true });
watch(selectedDrawing, id => {
if (chartApi) chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
if (chartApi) {
chartApi.setSelectedDrawing(id);
chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
}
});
watch(snap, value => {
@ -784,6 +861,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, 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 };
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, 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, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
},
}).mount('#app');

View file

@ -16,6 +16,7 @@ class ConfluenceChart {
this.chartEl = null;
this.clickListener = null;
this.selectedLineId = null;
this.selectedDrawingId = null;
this.anchorHandles = [];
this.lineHitTarget = null;
this.lineFocus = null;
@ -408,9 +409,22 @@ class ConfluenceChart {
this.commentLayer.appendChild(node.root);
}
node.comment = comment;
node.root.dataset.drawingId = comment.id;
node.root.classList.toggle('collapsed', !!comment.collapsed);
node.root.classList.toggle('floating', !comment.pinned);
const iconClass = ConfluenceChart.symbolIcons[comment.icon];
node.root.classList.toggle('symbol', !!iconClass);
node.collapse.hidden = !!iconClass;
node.collapseIcon.className = comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress';
node.grab.hidden = iconClass
? this.selectedDrawingId !== comment.id
: comment.pinned;
if (iconClass) {
node.icon.className = `fa-solid ${iconClass}`;
node.label.replaceChildren(node.icon);
} else {
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;
@ -482,30 +496,68 @@ class ConfluenceChart {
focus.setAttribute('aria-hidden', 'true');
const label = document.createElement('span');
label.className = 'chart-comment-text';
root.append(focus, label);
const node = { root, focus, label, comment };
const icon = document.createElement('i');
const collapse = document.createElement('button');
collapse.type = 'button';
collapse.className = 'chart-comment-collapse';
collapse.title = 'Collapse or expand';
collapse.setAttribute('aria-label', 'Collapse or expand comment');
const collapseIcon = document.createElement('i');
collapse.appendChild(collapseIcon);
const grab = document.createElement('button');
grab.type = 'button';
grab.className = 'chart-comment-grab';
grab.title = 'Drag';
grab.setAttribute('aria-label', 'Drag drawing');
const grabIcon = document.createElement('i');
grabIcon.className = 'fa-solid fa-grip-vertical';
grab.appendChild(grabIcon);
root.append(focus, collapse, grab, label);
const node = { root, focus, label, icon, collapse, collapseIcon, grab, comment };
root.addEventListener('click', event => {
event.stopPropagation();
this.focusComment(node.comment.id);
if (this.onCommentSelect) this.onCommentSelect(node.comment);
});
collapse.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.
root.addEventListener('pointerdown', event => {
if (node.comment.pinned) return;
grab.addEventListener('click', event => event.stopPropagation());
// Floating comments move in screen space. Symbols can also be moved while
// pinned, in which case their bar and tick-snapped price are updated.
grab.addEventListener('pointerdown', event => {
if (node.comment.pinned && !node.comment.icon) return;
event.preventDefault();
event.stopPropagation();
const bounds = this.chartEl.getBoundingClientRect();
this.focusComment(node.comment.id);
if (this.onCommentSelect) this.onCommentSelect(node.comment);
const bounds = this.plotCanvas().getBoundingClientRect();
const move = moveEvent => {
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - 8)}px`;
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - 8)}px`;
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - root.offsetWidth / 2)}px`;
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - root.offsetHeight / 2)}px`;
};
const up = upEvent => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
if (node.comment.pinned && node.comment.icon) {
const point = this.eventPoint(upEvent);
if (point && this.withinPlot(point) && this.onCommentMove) {
this.onCommentMove(node.comment, {
anchor_t: Math.round(point.t),
anchor_p: ConfluenceChart.snapToTick(point.p),
});
} else {
this.renderComments();
}
return;
}
const x = (upEvent.clientX - bounds.left - 8) / bounds.width;
const y = (upEvent.clientY - bounds.top - 8) / bounds.height;
const clamp = value => Math.min(Math.max(value, 0), 1);
if (this.onCommentMove) this.onCommentMove(node.comment, clamp(x), clamp(y));
if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) });
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
@ -804,6 +856,17 @@ class ConfluenceChart {
return { x, y, p: price, t: time == null ? projectedTime : Number(time) };
}
placementAtClient(clientX, clientY) {
const point = this.eventPoint({ clientX, clientY });
if (!point || !this.withinPlot(point)) return null;
return {
time: Math.round(point.t),
price: ConfluenceChart.snapToTick(point.p),
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
};
}
indexAtCoordinate(x) {
if (!this.bars.length) return null;
const scale = this.chart.timeScale();
@ -966,7 +1029,7 @@ class ConfluenceChart {
this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId);
if (tool === 'comment') {
if (tool === 'comment' || tool === 'symbol') {
// Both placements come from one gesture: the time and price under the
// cursor for a pinned comment, and the same spot as a fraction of the
// pane for a floating one, so the tool decides which without a second
@ -1144,6 +1207,11 @@ class ConfluenceChart {
this.renderAnchorHandles();
}
setSelectedDrawing(id) {
this.selectedDrawingId = id;
this.renderComments();
}
focusLine(id) {
this.setSelectedLine(id);
const level = this.levels.find(value => value.id === id && value.kind === 'manual');
@ -1606,6 +1674,21 @@ ConfluenceChart.tfColors = {
// colours they would be indistinguishable from the daily moving averages.
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
ConfluenceChart.symbolIcons = {
'arrow-up': 'fa-arrow-up',
'arrow-down': 'fa-arrow-down',
'face-smile': 'fa-face-smile',
'hand-point-right': 'fa-hand-point-right',
skull: 'fa-skull',
'face-laugh-squint': 'fa-face-laugh-squint',
'champagne-glasses': 'fa-champagne-glasses',
'arrow-left': 'fa-arrow-left',
'arrow-right': 'fa-arrow-right',
hand: 'fa-hand',
'right-to-bracket': 'fa-right-to-bracket',
play: 'fa-play',
};
ConfluenceChart.levelColor = level =>
level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf];

View file

@ -24,7 +24,7 @@
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div>
<div id="chart"></div>
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
<div class="statusbar">
<span>FEED <b>{{ status.stream }}</b></span>
<span>LAST BAR <b>{{ barAge }}</b></span>
@ -106,6 +106,42 @@
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
</div>
</div>
<div class="tool" :class="{armed: armedTool === 'symbol'}">
<div class="tool-head symbol-head" title="Choose a symbol and click the chart, or drag a symbol button onto it. Symbols pin to that bar and price and can be dragged later with their grip.">
<button class="symbol-arm" @click="toggleSymbolPanel" :aria-expanded="symbolPanelOpen" :aria-pressed="armedTool === 'symbol'">
<span class="tool-glyph"><i class="fa-solid fa-icons"></i></span>Symbol
</button>
<details class="color-picker symbol-head-color" @click.stop>
<summary :title="drawingColorName(symbolColor)"
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
:style="{backgroundColor: symbolColor}"></summary>
<div class="color-popover">
<button v-for="color in drawingColors" :key="color" type="button"
:class="{selected: color === symbolColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)} (${color})`"
@click.stop="symbolColor = color; $event.currentTarget.closest('details').open = false"></button>
<label class="custom-color" title="Custom color">
<i class="fa-solid fa-palette"></i>
<input type="color" v-model="symbolColor" aria-label="Custom symbol color">
</label>
<button type="button" class="palette-close" aria-label="Close color palette" title="Close"
@click.stop="$event.currentTarget.closest('details').open = false">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</details>
<span class="tool-state">{{ armedTool === 'symbol' ? 'click chart' : '' }}</span>
</div>
<div class="tool-body" v-show="symbolPanelOpen">
<div class="symbol-palette" aria-label="Symbol choices">
<button v-for="symbol in symbolChoices" :key="symbol.icon" type="button" draggable="true"
:class="{active: selectedSymbol === symbol.icon}" :title="symbol.name" :aria-label="symbol.name"
@click="chooseSymbol(symbol)" @dragstart="startSymbolDrag($event, symbol)">
<i :class="`fa-solid fa-${symbol.icon}`"></i>
</button>
</div>
</div>
</div>
</details>
<details class="sidebar-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
@ -115,6 +151,7 @@
<option value="trendline">Trendlines</option>
<option value="level">Levels</option>
<option value="comment">Comments</option>
<option value="symbol">Symbols</option>
</select>
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div>
@ -131,18 +168,18 @@
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedDrawings.includes(item.id)}"
@click="focusDrawing(item)">
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
<input v-if="item.line" class="line-select" type="checkbox"
: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>
<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-primary">
<input v-if="item.kind !== 'comment'" :value="item.line.label" aria-label="Drawing name"
<input v-if="item.line" :value="item.line.label" aria-label="Drawing name"
@click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)">
<label v-if="item.kind === 'comment'" class="drawing-state" @click.stop
<label v-if="item.comment" class="drawing-state" @click.stop
:title="item.comment.pinned ? 'Pinned to its bar' : 'Floating on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
<i :class="item.comment.pinned ? 'fa-solid fa-thumbtack' : 'fa-solid fa-up-down-left-right'"></i>
@ -159,10 +196,11 @@
<div class="drawing-secondary">
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
<span v-else-if="item.kind === 'level'">#{{ item.number }} · L {{ item.line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
<div class="drawing-controls" @click.stop v-if="item.kind !== 'comment'">
<div class="drawing-controls" @click.stop v-if="item.line">
<details class="color-picker">
<summary :aria-label="`Choose drawing color; current color ${drawingColorName(item.line.color || '#65b7cf')}`"
:title="drawingColorName(item.line.color || '#65b7cf')"
@ -209,7 +247,7 @@
</button>
</div>
</details>
<button class="collapse-toggle" @click.stop="toggleComment(item.comment)"
<button v-if="item.kind === 'comment'" class="collapse-toggle" @click.stop="toggleComment(item.comment)"
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>
</button>

View file

@ -34,6 +34,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
background:transparent; color:var(--fg); border:0; cursor:pointer; text-align:left; }
.tool-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); }
.tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, transparent); }
.symbol-head { cursor:default; }.symbol-arm { display:flex; align-items:center; gap:8px; flex:1; min-width:0; padding:0; border:0; color:var(--fg); text-align:left; }.symbol-head-color { width:24px; height:22px; }.symbol-head-color>summary { width:22px; height:20px; }.symbol-head-color .color-popover { top:27px; right:0; bottom:auto; }.tool:has(.symbol-head) { overflow:visible; }
.tool-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; }
.tool.armed .tool-glyph { color:var(--accent); }
.tool-state { margin-left:auto; font-size:10px; color:var(--accent); }
@ -43,6 +44,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.tool-body input, .tool-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.tool-body input[type=color] { padding:2px; height:26px; }
.symbol-palette { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }.symbol-palette button { display:grid; place-items:center; min-width:0; height:30px; padding:0; font-size:14px; }.symbol-palette button.active { color:var(--bg); }
.tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
.tool-body .check input { width:auto; }
.price-row { grid-template-columns:1fr auto; }
@ -62,10 +64,12 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg);
font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap;
box-shadow:0 1px 3px rgba(0,0,0,.16); }
.chart-comment.collapsed { max-width:none; width:18px; height:18px; padding:0; display:flex;
align-items:center; justify-content:center; border-radius:50%; border-left-width:1px;
.chart-comment.collapsed { max-width:none; width:34px; height:20px; padding:0; display:flex;
align-items:center; justify-content:flex-end; border-radius:4px; border-left-width:1px;
font-size:9px; white-space:nowrap; }
.chart-comment.floating { border-style:dashed; cursor:grab; }
.chart-comment.floating { border-style:dashed; }
.chart-comment:not(.symbol) { padding-left:19px; }.chart-comment-collapse { position:absolute; inset:0 auto 0 0; display:grid; place-items:center; width:14px; padding:0; border:0; border-right:1px solid var(--line); color:var(--muted); font-size:7px; }.chart-comment-collapse[hidden],.chart-comment-grab[hidden] { display:none; }.chart-comment-grab { position:absolute; right:-9px; top:-9px; z-index:2; display:grid; place-items:center; width:18px; height:18px; padding:0; border:1px solid var(--line); border-radius:50%; background:var(--panel); color:var(--muted); font-size:8px; cursor:grab; opacity:.7; }.chart-comment-grab:hover { opacity:1; color:var(--fg); }.chart-comment-grab:active { cursor:grabbing; }
.chart-comment.floating { cursor:pointer; }.chart-comment.symbol { display:grid; place-items:center; width:30px; height:30px; padding:0; border-radius:50%; color:var(--comment-focus-color); font-size:18px; cursor:pointer; }.chart-comment.symbol .chart-comment-text { display:grid; place-items:center; }
.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 {

View file

@ -78,11 +78,15 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
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}"]`);
const chartComment = page.locator(`.chart-comment[title="${TEXT}"]`);
// The body selects without collapsing; collapse is deliberately confined
// to the narrow control on the left.
await chartComment.locator('.chart-comment-text').click();
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
'clicking the chart comment did not select its drawing row');
assert.equal((await commentNode(page)).collapsed, false,
'selecting the comment also collapsed it');
await chartComment.locator('.chart-comment-collapse').click();
await page.waitForTimeout(1200);
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
assertNoPageErrors(page, assert);
@ -118,3 +122,65 @@ test('a comment is never a level', { timeout: 180000 }, async () => {
assertNoPageErrors(page, assert);
});
});
test('a symbol can be dropped at a price and dragged to a new one', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Symbol');
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
'the two-row symbol palette is incomplete');
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,
'the Go symbol is not represented by a play triangle');
await page.locator('.symbol-head-color>summary').click();
assert.equal(await page.locator('.symbol-head-color .color-popover>button[title="Cyan"]').count(), 1,
'the symbol color palette does not expose named presets');
assert.equal(await page.locator('.symbol-head-color .color-popover>button[aria-label^="Use "]').count(), 16,
'the symbol color palette does not contain all presets');
await page.locator('.symbol-head-color .palette-close').click();
await page.locator('button[aria-label="Skull"]').dragTo(page.locator('#chart'), {
targetPosition: { x: box.w * 0.55, y: box.h * 0.4 },
});
await page.waitForFunction(() => window.__chart.comments.some(comment =>
comment.kind === 'symbol' && comment.icon === 'skull'));
const symbolTool = page.locator('.tool:has(.symbol-head)');
assert.equal(await symbolTool.locator('.tool-body').isVisible(), true,
'placing a symbol closed its tool palette');
assert.equal(await symbolTool.evaluate(node => node.classList.contains('armed')), false,
'placing a symbol left the chart armed to create another one');
const created = await page.evaluate(() => {
const symbol = window.__chart.comments.find(comment =>
comment.kind === 'symbol' && comment.icon === 'skull');
return { id: symbol.id, anchor_t: symbol.anchor_t, anchor_p: symbol.anchor_p };
});
assert.equal(created.anchor_p * 4, Math.round(created.anchor_p * 4),
'the dropped symbol price was not tick-snapped');
const node = page.locator(`.chart-comment.symbol[data-drawing-id="${created.id}"]`);
assert.equal(await node.locator('.fa-skull').count(), 1,
'the symbol did not render with its approved Font Awesome icon');
assert.equal(await page.evaluate(id =>
window.__chart.levels.some(level => level.id === id), created.id), false,
'the symbol leaked into chart levels');
assert.equal(await node.locator('.chart-comment-grab').isVisible(), false,
'an unselected symbol exposed its grab handle');
await node.locator('.chart-comment-text').click();
assert.equal(await node.locator('.chart-comment-grab').isVisible(), true,
'a selected symbol did not expose its grab handle');
const before = await node.boundingBox();
const grip = await node.locator('.chart-comment-grab').boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2 + 70, grip.y + grip.height / 2 + 45,
{ steps: 10 });
await page.mouse.up();
await page.waitForFunction(previous => {
const symbol = window.__chart.comments.find(comment => comment.id === previous.id);
return symbol && (symbol.anchor_t !== previous.anchor_t || symbol.anchor_p !== previous.anchor_p);
}, created);
const movedPrice = await page.evaluate(id =>
window.__chart.comments.find(comment => comment.id === id).anchor_p, created.id);
assert.equal(movedPrice * 4, Math.round(movedPrice * 4),
'the dragged symbol price was not tick-snapped');
assertNoPageErrors(page, assert);
});
});

View file

@ -52,7 +52,7 @@ def test_manual_line_raises_existing_ma_cluster_score():
assert after.score == 16
def test_a_comment_is_never_a_level(tmp_path):
def test_an_annotation_is_never_a_level(tmp_path):
# Comments live with the lines so they share numbering, filtering and
# deletion — but a comment reaching levels() would join a confluence
# cluster and fire a push notification about a piece of text.
@ -66,9 +66,11 @@ def test_a_comment_is_never_a_level(tmp_path):
store.add(ManualLine(id="ml_level", slope=0.0, **common))
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
note="watch this", **common))
store.add(ManualLine(id="ml_symbol", slope=0.0, kind="symbol",
icon="skull", note="Skull", **common))
assert [level.id for level in store.levels()] == ["ml_level"]
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note"]
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note", "ml_symbol"]
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):