symbol improvements
This commit is contained in:
parent
0bca5d3bb7
commit
e957991242
9 changed files with 324 additions and 44 deletions
|
|
@ -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", "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
105
static/chart.js
105
static/chart.js
|
|
@ -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);
|
||||||
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
|
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.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];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue