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

View file

@ -4,6 +4,7 @@ import json
import logging import logging
import time import time
import uuid import uuid
from typing import Literal
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response 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) y: float = Field(0.12, ge=0.0, le=1.0)
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$") color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
tf: Timeframe = Timeframe.M1 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) @router.post("/comments", status_code=201)
@ -241,7 +247,8 @@ def create_comment(request: Request, payload: CommentCreate):
created_at=now, created_at=now,
note=payload.text, note=payload.text,
color=payload.color, color=payload.color,
kind="comment", kind="symbol" if payload.icon else "comment",
icon=payload.icon or "",
pinned=payload.pinned, pinned=payload.pinned,
x=payload.x, x=payload.x,
y=payload.y, y=payload.y,

View file

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

View file

@ -120,6 +120,9 @@ createApp({
const drawSide = ref('support'); const drawSide = ref('support');
const commentText = ref(''); const commentText = ref('');
const commentFloat = ref(false); 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 // Comments are not levels, so they never arrive over the levels socket and
// are fetched and refreshed explicitly. // are fetched and refreshed explicitly.
const comments = ref([]); const comments = ref([]);
@ -148,6 +151,20 @@ createApp({
'#9fb0c4': 'Slate', '#746c60': 'Taupe', '#2c2924': 'Charcoal', '#c8992f': 'Ochre', '#9fb0c4': 'Slate', '#746c60': 'Taupe', '#2c2924': 'Charcoal', '#c8992f': 'Ochre',
}; };
const drawingColorName = color => drawingColorNames[color] || color; 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()); const now = ref(Date.now());
let chartApi = null; let chartApi = null;
let socket = null; let socket = null;
@ -183,7 +200,7 @@ createApp({
const notes = comments.value.map(comment => ({ const notes = comments.value.map(comment => ({
id: comment.id, id: comment.id,
number: comment.number, number: comment.number,
kind: 'comment', kind: comment.kind === 'symbol' ? 'symbol' : 'comment',
label: comment.note || '', label: comment.note || '',
comment, comment,
})); }));
@ -374,6 +391,7 @@ createApp({
function armTool(tool) { function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool; armedTool.value = armedTool.value === tool ? null : tool;
if (tool !== 'symbol') symbolPanelOpen.value = false;
selectedDrawing.value = null; selectedDrawing.value = null;
selectedDrawings.value = []; selectedDrawings.value = [];
chartApi.armTool(armedTool.value); chartApi.armTool(armedTool.value);
@ -387,13 +405,18 @@ createApp({
} }
async function handleToolComplete(result) { 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; armedTool.value = null;
chartApi.armTool(null); chartApi.armTool(null);
if (result.tool === 'comment') { if (result.tool === 'comment') {
await createComment(result); await createComment(result);
return; return;
} }
if (result.tool === 'symbol') {
await createSymbol(result);
return;
}
if (result.tool === 'level') { if (result.tool === 'level') {
alertPrice.value = result.price; alertPrice.value = result.price;
await addPriceAlert(); await addPriceAlert();
@ -406,7 +429,7 @@ createApp({
const response = await apiFetch('/api/drawings'); const response = await apiFetch('/api/drawings');
if (!response.ok) return; if (!response.ok) return;
const payload = await response.json(); 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(); syncComments();
} }
@ -435,6 +458,57 @@ createApp({
await refreshComments(); 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) { async function patchComment(comment, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
@ -444,12 +518,12 @@ createApp({
} }
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed }); 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 }); const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
async function deleteDrawing(item) { async function deleteDrawing(item) {
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.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; if (selectedDrawing.value === item.id) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id); selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id);
@ -593,7 +667,7 @@ createApp({
function focusDrawing(item) { function focusDrawing(item) {
selectedDrawing.value = item.id; selectedDrawing.value = item.id;
selectedDrawings.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); else chartApi?.focusLine(item.id);
} }
@ -635,7 +709,7 @@ createApp({
const saved = await response.json(); const saved = await response.json();
// A comment is not a level and must not be pushed into the level list, // A comment is not a level and must not be pushed into the level list,
// where it would be drawn as a line. // 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); levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels(); syncVisibleLevels();
} }
@ -748,7 +822,10 @@ createApp({
}, { deep: true }); }, { deep: true });
watch(selectedDrawing, id => { 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 => { watch(snap, value => {
@ -784,6 +861,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -16,6 +16,7 @@ class ConfluenceChart {
this.chartEl = null; this.chartEl = null;
this.clickListener = null; this.clickListener = null;
this.selectedLineId = null; this.selectedLineId = null;
this.selectedDrawingId = null;
this.anchorHandles = []; this.anchorHandles = [];
this.lineHitTarget = null; this.lineHitTarget = null;
this.lineFocus = null; this.lineFocus = null;
@ -408,9 +409,22 @@ class ConfluenceChart {
this.commentLayer.appendChild(node.root); this.commentLayer.appendChild(node.root);
} }
node.comment = comment; node.comment = comment;
node.root.dataset.drawingId = comment.id;
node.root.classList.toggle('collapsed', !!comment.collapsed); node.root.classList.toggle('collapsed', !!comment.collapsed);
node.root.classList.toggle('floating', !comment.pinned); 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.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.style.setProperty('--comment-focus-color', comment.color || '#c8992f');
node.root.title = comment.note; node.root.title = comment.note;
@ -482,30 +496,68 @@ class ConfluenceChart {
focus.setAttribute('aria-hidden', 'true'); 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.append(focus, label); const icon = document.createElement('i');
const node = { root, focus, label, comment }; 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 => { root.addEventListener('click', event => {
event.stopPropagation(); event.stopPropagation();
this.focusComment(node.comment.id); this.focusComment(node.comment.id);
if (this.onCommentSelect) this.onCommentSelect(node.comment); 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); if (this.onCommentToggle) this.onCommentToggle(node.comment);
}); });
// Floating comments are dragged; pinned ones belong to their bar. grab.addEventListener('click', event => event.stopPropagation());
root.addEventListener('pointerdown', event => { // Floating comments move in screen space. Symbols can also be moved while
if (node.comment.pinned) return; // 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(); 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 => { const move = moveEvent => {
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - 8)}px`; root.style.left = `${Math.round(moveEvent.clientX - bounds.left - root.offsetWidth / 2)}px`;
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - 8)}px`; root.style.top = `${Math.round(moveEvent.clientY - bounds.top - root.offsetHeight / 2)}px`;
}; };
const up = upEvent => { const up = upEvent => {
window.removeEventListener('pointermove', move); window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up); 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 x = (upEvent.clientX - bounds.left - 8) / bounds.width;
const y = (upEvent.clientY - bounds.top - 8) / bounds.height; const y = (upEvent.clientY - bounds.top - 8) / bounds.height;
const clamp = value => Math.min(Math.max(value, 0), 1); 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('pointermove', move);
window.addEventListener('pointerup', up); window.addEventListener('pointerup', up);
@ -804,6 +856,17 @@ class ConfluenceChart {
return { x, y, p: price, t: time == null ? projectedTime : Number(time) }; 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) { indexAtCoordinate(x) {
if (!this.bars.length) return null; if (!this.bars.length) return null;
const scale = this.chart.timeScale(); const scale = this.chart.timeScale();
@ -966,7 +1029,7 @@ class ConfluenceChart {
this.clearGesture(); this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId); 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 // 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 // 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 // pane for a floating one, so the tool decides which without a second
@ -1144,6 +1207,11 @@ class ConfluenceChart {
this.renderAnchorHandles(); this.renderAnchorHandles();
} }
setSelectedDrawing(id) {
this.selectedDrawingId = id;
this.renderComments();
}
focusLine(id) { focusLine(id) {
this.setSelectedLine(id); this.setSelectedLine(id);
const level = this.levels.find(value => value.id === id && value.kind === 'manual'); 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. // colours they would be indistinguishable from the daily moving averages.
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' }; 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 => ConfluenceChart.levelColor = level =>
level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf]; 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><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 class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div> </div>
<div id="chart"></div> <div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
<div class="statusbar"> <div class="statusbar">
<span>FEED <b>{{ status.stream }}</b></span> <span>FEED <b>{{ status.stream }}</b></span>
<span>LAST BAR <b>{{ barAge }}</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> <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> </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>
<details class="sidebar-section" open> <details class="sidebar-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary> <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="trendline">Trendlines</option>
<option value="level">Levels</option> <option value="level">Levels</option>
<option value="comment">Comments</option> <option value="comment">Comments</option>
<option value="symbol">Symbols</option>
</select> </select>
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text"> <input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div> </div>
@ -131,18 +168,18 @@
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row" <div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedDrawings.includes(item.id)}" :class="{selected: selectedDrawings.includes(item.id)}"
@click="focusDrawing(item)"> @click="focusDrawing(item)">
<input v-if="item.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}`" :checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)"> @click.stop @change="toggleDrawingSelection(item.id)">
<span v-else class="line-select drawing-icon"><i class="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-content">
<div class="drawing-primary"> <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)"> @click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text" <input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)"> @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'"> :title="item.comment.pinned ? 'Pinned to its bar' : 'Floating on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)"> <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> <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"> <div class="drawing-secondary">
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span> <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-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> <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"> <details class="color-picker">
<summary :aria-label="`Choose drawing color; current color ${drawingColorName(item.line.color || '#65b7cf')}`" <summary :aria-label="`Choose drawing color; current color ${drawingColorName(item.line.color || '#65b7cf')}`"
:title="drawingColorName(item.line.color || '#65b7cf')" :title="drawingColorName(item.line.color || '#65b7cf')"
@ -209,7 +247,7 @@
</button> </button>
</div> </div>
</details> </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'"> :title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i> <i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>
</button> </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; } 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-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); }
.tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, 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-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; }
.tool.armed .tool-glyph { color:var(--accent); } .tool.armed .tool-glyph { color:var(--accent); }
.tool-state { margin-left:auto; font-size:10px; 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; .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); } border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.tool-body input[type=color] { padding:2px; height:26px; } .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 { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
.tool-body .check input { width:auto; } .tool-body .check input { width:auto; }
.price-row { grid-template-columns:1fr 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); 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; 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); } box-shadow:0 1px 3px rgba(0,0,0,.16); }
.chart-comment.collapsed { max-width:none; width:18px; height:18px; padding:0; display:flex; .chart-comment.collapsed { max-width:none; width:34px; height:20px; padding:0; display:flex;
align-items:center; justify-content:center; border-radius:50%; border-left-width:1px; align-items:center; justify-content:flex-end; border-radius:4px; 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; }
.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 { 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; } .chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; }
@keyframes comment-focus { @keyframes comment-focus {

View file

@ -78,11 +78,15 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false, assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection'); 'Escape did not clear comment selection');
// By title, not by class: the dev store is shared, so other people's const chartComment = page.locator(`.chart-comment[title="${TEXT}"]`);
// comments are on the chart too and `.chart-comment` matches them first. // The body selects without collapsing; collapse is deliberately confined
await page.click(`.chart-comment[title="${TEXT}"]`); // 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, assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
'clicking the chart comment did not select its drawing row'); '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); 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);
@ -118,3 +122,65 @@ test('a comment is never a level', { timeout: 180000 }, async () => {
assertNoPageErrors(page, assert); 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 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 # Comments live with the lines so they share numbering, filtering and
# deletion — but a comment reaching levels() would join a confluence # deletion — but a comment reaching levels() would join a confluence
# cluster and fire a push notification about a piece of text. # 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_level", slope=0.0, **common))
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment", store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
note="watch this", **common)) 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 [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): def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):