undo
This commit is contained in:
parent
6e69b7337a
commit
dae60fdf67
7 changed files with 343 additions and 33 deletions
|
|
@ -101,6 +101,39 @@ class PriceAlertCreate(BaseModel):
|
|||
alert_early_points: float | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class LineRestore(BaseModel):
|
||||
"""Re-insert a deleted drawing with the same id and number.
|
||||
|
||||
Create always mints a new id. Undo of delete needs the original identity
|
||||
back, including drawing number, or clusters and the sidebar # label drift.
|
||||
"""
|
||||
|
||||
id: str
|
||||
tf: Timeframe
|
||||
side: Side
|
||||
anchor_t: int
|
||||
anchor_p: float
|
||||
slope: float
|
||||
last_t: int
|
||||
created_at: int | None = None
|
||||
note: str = ""
|
||||
label: str = ""
|
||||
hidden: bool = False
|
||||
color: str | None = None
|
||||
line_width: int | None = None
|
||||
number: int = 0
|
||||
cutoff_t: int | None = None
|
||||
armed: bool = True
|
||||
kind: str = ""
|
||||
pinned: bool = True
|
||||
x: float = Field(0.72, ge=0.0, le=1.0)
|
||||
y: float = Field(0.12, ge=0.0, le=1.0)
|
||||
collapsed: bool = False
|
||||
icon: str = ""
|
||||
alert_early_points: float | None = None
|
||||
scale: float = Field(1.0, ge=0.5, le=3)
|
||||
|
||||
|
||||
class LinePatch(BaseModel):
|
||||
side: Side | None = None
|
||||
note: str | None = None
|
||||
|
|
@ -243,6 +276,45 @@ def create_line(request: Request, payload: LineCreate):
|
|||
return positioned_level(runtime, line.id).to_dict()
|
||||
|
||||
|
||||
@router.post("/lines/restore", status_code=201)
|
||||
def restore_line(request: Request, payload: LineRestore):
|
||||
runtime = request.app.state.runtime
|
||||
if payload.id in runtime.manual_lines.lines:
|
||||
raise HTTPException(409, "Line already exists")
|
||||
kind = "" if payload.kind in {"", "manual", "trendline", "level"} else payload.kind
|
||||
color = payload.color if payload.color and len(payload.color) == 7 and payload.color.startswith("#") else "#65b7cf"
|
||||
line = ManualLine(
|
||||
id=payload.id,
|
||||
tf=payload.tf,
|
||||
side=payload.side,
|
||||
anchor_t=payload.anchor_t,
|
||||
anchor_p=payload.anchor_p,
|
||||
slope=payload.slope,
|
||||
last_t=payload.last_t,
|
||||
created_at=payload.created_at or int(time.time()),
|
||||
note=payload.note or payload.label,
|
||||
hidden=payload.hidden,
|
||||
color=color,
|
||||
line_width=payload.line_width or 2,
|
||||
number=payload.number,
|
||||
cutoff_t=payload.cutoff_t,
|
||||
armed=payload.armed,
|
||||
kind=kind,
|
||||
pinned=payload.pinned,
|
||||
x=payload.x,
|
||||
y=payload.y,
|
||||
collapsed=payload.collapsed,
|
||||
icon=payload.icon,
|
||||
alert_early_points=payload.alert_early_points,
|
||||
scale=payload.scale,
|
||||
)
|
||||
line = runtime.manual_lines.add(line)
|
||||
runtime.rebuild_levels()
|
||||
if line.is_overlay:
|
||||
return {**line.to_dict(), "kind": line.drawing_kind}
|
||||
return positioned_level(runtime, line.id).to_dict()
|
||||
|
||||
|
||||
@router.post("/lines/price", status_code=201)
|
||||
def create_price_alert(request: Request, payload: PriceAlertCreate):
|
||||
runtime = request.app.state.runtime
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# Undo and Redo
|
||||
|
||||
Status: proposed.
|
||||
Status: proposed. An in-session client stack (Ctrl/Cmd+Z and an undo
|
||||
button) plus `POST /api/lines/restore` shipped as an interim so delete
|
||||
undo keeps the same id and number. Redo and the command journal are still
|
||||
unbuilt.
|
||||
|
||||
## Goal
|
||||
|
||||
|
|
|
|||
216
static/app.js
216
static/app.js
|
|
@ -141,6 +141,8 @@ createApp({
|
|||
const selectedDrawing = ref(null);
|
||||
const selectedDrawings = ref([]);
|
||||
const drawingList = ref(null);
|
||||
const undoStack = ref([]);
|
||||
let applyingUndo = false;
|
||||
const alertPrice = ref(null);
|
||||
const alertNote = ref('');
|
||||
const levelColor = ref('#e0a34a');
|
||||
|
|
@ -731,6 +733,92 @@ createApp({
|
|||
chartApi.setComments(visible);
|
||||
}
|
||||
|
||||
const canUndo = computed(() => undoStack.value.length > 0);
|
||||
const undoTitle = computed(() => {
|
||||
const last = undoStack.value.at(-1);
|
||||
return last ? `Undo ${last.label}` : 'Undo';
|
||||
});
|
||||
|
||||
function remember(label, invert) {
|
||||
if (applyingUndo) return;
|
||||
undoStack.value = [...undoStack.value.slice(-49), { label, invert }];
|
||||
}
|
||||
|
||||
function overlayDrawing(item) {
|
||||
return Boolean(
|
||||
item.comment
|
||||
|| ['comment', 'symbol', 'fibonacci'].includes(item.kind)
|
||||
|| ['comment', 'symbol', 'fibonacci'].includes(item.line?.kind),
|
||||
);
|
||||
}
|
||||
|
||||
function drawingSnapshot(item) {
|
||||
const drawing = item.comment || item.line;
|
||||
if (!drawing) return null;
|
||||
const kind = item.kind === 'fibonacci' || drawing.kind === 'fibonacci' ? 'fibonacci'
|
||||
: item.kind === 'symbol' || drawing.kind === 'symbol' ? 'symbol'
|
||||
: item.kind === 'comment' || drawing.kind === 'comment' ? 'comment'
|
||||
: '';
|
||||
return {
|
||||
id: drawing.id,
|
||||
tf: drawing.tf,
|
||||
side: drawing.side || 'support',
|
||||
anchor_t: drawing.anchor_t,
|
||||
anchor_p: drawing.anchor_p,
|
||||
slope: drawing.slope ?? 0,
|
||||
last_t: drawing.last_t ?? drawing.anchor_t,
|
||||
created_at: drawing.created_at || Math.floor(Date.now() / 1000),
|
||||
note: drawing.note || drawing.label || '',
|
||||
hidden: !!drawing.hidden,
|
||||
color: drawing.color || '#65b7cf',
|
||||
line_width: drawing.line_width || 2,
|
||||
number: drawing.number || 0,
|
||||
cutoff_t: drawing.cutoff_t ?? null,
|
||||
armed: drawing.armed !== false,
|
||||
kind,
|
||||
pinned: drawing.pinned !== false,
|
||||
x: drawing.x ?? 0.72,
|
||||
y: drawing.y ?? 0.12,
|
||||
collapsed: !!drawing.collapsed,
|
||||
icon: drawing.icon || '',
|
||||
alert_early_points: drawing.alert_early_points ?? null,
|
||||
scale: drawing.scale || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreDrawings(snapshots) {
|
||||
for (const snapshot of snapshots) {
|
||||
const response = await apiFetch('/api/lines/restore', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`Unable to restore drawing: HTTP ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
const saved = await response.json();
|
||||
if (['comment', 'symbol', 'fibonacci'].includes(snapshot.kind)) continue;
|
||||
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
|
||||
}
|
||||
if (snapshots.some(item => ['comment', 'symbol', 'fibonacci'].includes(item.kind))) {
|
||||
await refreshComments();
|
||||
}
|
||||
syncVisibleLevels();
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
if (chartApi?.clearPendingAnchor()) return;
|
||||
const last = undoStack.value.at(-1);
|
||||
if (!last || applyingUndo) return;
|
||||
applyingUndo = true;
|
||||
undoStack.value = undoStack.value.slice(0, -1);
|
||||
try {
|
||||
await last.invert();
|
||||
} finally {
|
||||
applyingUndo = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createComment(result) {
|
||||
const text = commentText.value.trim();
|
||||
if (!text) return;
|
||||
|
|
@ -748,8 +836,10 @@ createApp({
|
|||
console.error('Unable to save comment', response.status);
|
||||
return;
|
||||
}
|
||||
const saved = await response.json();
|
||||
commentText.value = '';
|
||||
await refreshComments();
|
||||
remember('comment', () => deleteDrawing({ id: saved.id, kind: 'comment' }));
|
||||
}
|
||||
|
||||
async function createSymbol(result, icon = selectedSymbol.value) {
|
||||
|
|
@ -766,7 +856,9 @@ createApp({
|
|||
console.error('Unable to save symbol', response.status);
|
||||
return;
|
||||
}
|
||||
const saved = await response.json();
|
||||
await refreshComments();
|
||||
remember('mark', () => deleteDrawing({ id: saved.id, kind: 'symbol' }));
|
||||
}
|
||||
|
||||
function chooseSymbol(symbol) {
|
||||
|
|
@ -808,20 +900,36 @@ createApp({
|
|||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
if (response.ok) await refreshComments();
|
||||
if (!response.ok) return false;
|
||||
await refreshComments();
|
||||
return true;
|
||||
}
|
||||
|
||||
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
|
||||
const moveComment = (comment, changes) => patchComment(comment, changes);
|
||||
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
|
||||
async function moveComment(comment, changes) {
|
||||
const previous = {};
|
||||
for (const key of Object.keys(changes)) previous[key] = comment[key];
|
||||
if (Object.keys(changes).every(key => changes[key] === previous[key])) return;
|
||||
if (await patchComment(comment, changes)) {
|
||||
remember('move', () => patchComment(comment, previous));
|
||||
}
|
||||
}
|
||||
async function togglePinned(comment) {
|
||||
const previous = comment.pinned;
|
||||
if (await patchComment(comment, { pinned: !comment.pinned })) {
|
||||
remember('pin', () => patchComment(comment, { pinned: previous }));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDrawing(item) {
|
||||
const snapshot = applyingUndo ? null : drawingSnapshot(item);
|
||||
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
|
||||
if (item.comment) await refreshComments();
|
||||
if (overlayDrawing(item)) await refreshComments();
|
||||
else levels.value = levels.value.filter(level => level.id !== item.id);
|
||||
if (selectedDrawing.value === item.id) selectedDrawing.value = null;
|
||||
selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id);
|
||||
syncVisibleLevels();
|
||||
if (snapshot) remember('delete', () => restoreDrawings([snapshot]));
|
||||
}
|
||||
|
||||
async function createFibonacci(start, end) {
|
||||
|
|
@ -839,10 +947,11 @@ createApp({
|
|||
console.error(`Unable to save fibonacci: HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
await refreshComments();
|
||||
const saved = await response.json();
|
||||
await refreshComments();
|
||||
selectedDrawing.value = saved.id;
|
||||
selectedDrawings.value = [saved.id];
|
||||
remember('fibonacci', () => deleteDrawing({ id: saved.id, kind: 'fibonacci' }));
|
||||
}
|
||||
|
||||
async function createTrendline(first, second) {
|
||||
|
|
@ -870,6 +979,7 @@ createApp({
|
|||
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
|
||||
selectedDrawing.value = saved.id;
|
||||
selectedDrawings.value = [saved.id];
|
||||
remember('trendline', () => deleteDrawing({ id: saved.id }));
|
||||
} catch (error) {
|
||||
levels.value = levels.value.filter(level => level.id !== temporaryId);
|
||||
console.error('Unable to save line', error);
|
||||
|
|
@ -890,6 +1000,7 @@ createApp({
|
|||
if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; }
|
||||
const saved = await response.json();
|
||||
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
|
||||
remember('level', () => deleteDrawing({ id: saved.id }));
|
||||
alertPrice.value = null;
|
||||
alertNote.value = '';
|
||||
alertEarlyPoints.value = null;
|
||||
|
|
@ -899,9 +1010,21 @@ createApp({
|
|||
async function deleteSelected() {
|
||||
const ids = selectedDrawings.value.length
|
||||
? selectedDrawings.value : [selectedDrawing.value].filter(Boolean);
|
||||
for (const id of ids) {
|
||||
const item = drawings.value.find(drawing => drawing.id === id);
|
||||
if (item) await deleteDrawing(item);
|
||||
const snapshots = ids
|
||||
.map(id => drawings.value.find(drawing => drawing.id === id))
|
||||
.map(item => item && drawingSnapshot(item))
|
||||
.filter(Boolean);
|
||||
applyingUndo = true;
|
||||
try {
|
||||
for (const id of ids) {
|
||||
const item = drawings.value.find(drawing => drawing.id === id);
|
||||
if (item) await deleteDrawing(item);
|
||||
}
|
||||
} finally {
|
||||
applyingUndo = false;
|
||||
}
|
||||
if (snapshots.length) {
|
||||
remember(snapshots.length > 1 ? 'delete drawings' : 'delete', () => restoreDrawings(snapshots));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -948,10 +1071,15 @@ createApp({
|
|||
selectedDrawing.value = saved.id;
|
||||
selectedDrawings.value = [saved.id];
|
||||
syncVisibleLevels();
|
||||
remember('duplicate', () => deleteDrawing({ id: saved.id }));
|
||||
}
|
||||
|
||||
async function deleteLines(ids) {
|
||||
if (!ids.length) return;
|
||||
const snapshots = ids
|
||||
.map(id => drawings.value.find(drawing => drawing.id === id))
|
||||
.map(item => item && drawingSnapshot(item))
|
||||
.filter(Boolean);
|
||||
const deleting = new Set(ids);
|
||||
levels.value = levels.value.filter(level => !deleting.has(level.id));
|
||||
if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null;
|
||||
|
|
@ -964,6 +1092,9 @@ createApp({
|
|||
responses.forEach(response => {
|
||||
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
|
||||
});
|
||||
if (snapshots.length) {
|
||||
remember(snapshots.length > 1 ? 'delete drawings' : 'delete', () => restoreDrawings(snapshots));
|
||||
}
|
||||
}
|
||||
|
||||
function selectComment(comment) {
|
||||
|
|
@ -993,19 +1124,31 @@ createApp({
|
|||
|
||||
async function toggleSelectedVisibility() {
|
||||
const hidden = !selectedAreHidden.value;
|
||||
for (const id of [...selectedDrawings.value]) {
|
||||
const previous = [...selectedDrawings.value].map(id => {
|
||||
const item = drawings.value.find(drawing => drawing.id === id);
|
||||
const drawing = item?.line || item?.comment;
|
||||
if (drawing) await updateLineStyle(drawing, { hidden });
|
||||
return drawing ? { drawing, hidden: drawing.hidden } : null;
|
||||
}).filter(Boolean);
|
||||
for (const entry of previous) await updateLineStyle(entry.drawing, { hidden });
|
||||
if (previous.length) {
|
||||
remember(hidden ? 'hide' : 'show', async () => {
|
||||
for (const entry of previous) await updateLineStyle(entry.drawing, { hidden: entry.hidden });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function setArmed(line, armed) {
|
||||
await updateLineStyle(line, { armed });
|
||||
const previous = line.armed;
|
||||
if (await updateLineStyle(line, { armed })) {
|
||||
remember(armed ? 'arm' : 'disarm', () => updateLineStyle(line, { armed: previous }));
|
||||
}
|
||||
}
|
||||
|
||||
async function renameLine(line, name) {
|
||||
await updateLineStyle(line, { note: name.trim() });
|
||||
const previous = line.note ?? line.label ?? '';
|
||||
if (await updateLineStyle(line, { note: name.trim() })) {
|
||||
remember('rename', () => updateLineStyle(line, { note: previous }));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLineStyle(line, changes) {
|
||||
|
|
@ -1013,23 +1156,27 @@ createApp({
|
|||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
|
||||
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return false; }
|
||||
const saved = await response.json();
|
||||
// A comment is not a level and must not be pushed into the level list,
|
||||
// where it would be drawn as a line.
|
||||
if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return; }
|
||||
if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return true; }
|
||||
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||
syncVisibleLevels();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateLevelNumber(line, field, raw) {
|
||||
const value = raw === '' ? null : Number(raw);
|
||||
if (field === 'anchor_p' && !(value > 0)) return;
|
||||
if (field === 'alert_early_points' && value != null && value < 0) return;
|
||||
await updateLineStyle(line, { [field]: value });
|
||||
const previous = line[field];
|
||||
if (await updateLineStyle(line, { [field]: value })) {
|
||||
remember('edit', () => updateLineStyle(line, { [field]: previous }));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLineGeometry(line) {
|
||||
async function updateLineGeometry(line, previous = null) {
|
||||
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -1039,12 +1186,19 @@ createApp({
|
|||
});
|
||||
if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; }
|
||||
const saved = await response.json();
|
||||
if (line.kind === 'fibonacci') { await refreshComments(); return; }
|
||||
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||
syncVisibleLevels();
|
||||
if (line.kind === 'fibonacci') await refreshComments();
|
||||
else {
|
||||
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||
syncVisibleLevels();
|
||||
}
|
||||
if (previous) {
|
||||
remember('move', () => updateLineGeometry(previous));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCommentGeometry(comment, changes) {
|
||||
const previous = {};
|
||||
for (const key of Object.keys(changes)) previous[key] = comment[key];
|
||||
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changes),
|
||||
|
|
@ -1053,6 +1207,7 @@ createApp({
|
|||
const saved = await response.json();
|
||||
comments.value = comments.value.map(value => value.id === comment.id ? saved : value);
|
||||
syncComments();
|
||||
remember('move', () => updateCommentGeometry(comment, previous));
|
||||
}
|
||||
|
||||
async function nudgeSelectedDrawings(key, multiplier) {
|
||||
|
|
@ -1066,9 +1221,10 @@ createApp({
|
|||
if (!item) continue;
|
||||
if (item.line) {
|
||||
const line = { ...item.line };
|
||||
const previous = { ...item.line };
|
||||
if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK);
|
||||
if (line.slope === 0) {
|
||||
if (vertical) await updateLineGeometry(line);
|
||||
if (vertical) await updateLineGeometry(line, previous);
|
||||
continue;
|
||||
}
|
||||
const endPrice = item.line.anchor_p
|
||||
|
|
@ -1083,7 +1239,7 @@ createApp({
|
|||
}
|
||||
}
|
||||
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
|
||||
await updateLineGeometry(line);
|
||||
await updateLineGeometry(line, previous);
|
||||
continue;
|
||||
}
|
||||
const comment = item.comment;
|
||||
|
|
@ -1114,7 +1270,7 @@ createApp({
|
|||
}
|
||||
}
|
||||
|
||||
async function endLineHere(line) {
|
||||
async function endLineHere(line, previousCutoff) {
|
||||
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cutoff_t: line.cutoff_t }),
|
||||
|
|
@ -1123,6 +1279,9 @@ createApp({
|
|||
const saved = await response.json();
|
||||
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||
syncVisibleLevels();
|
||||
if (previousCutoff !== undefined) {
|
||||
remember('end line', () => updateLineStyle(line, { cutoff_t: previousCutoff }));
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace is a normal editing key inside a field. Without this guard,
|
||||
|
|
@ -1158,6 +1317,13 @@ createApp({
|
|||
}
|
||||
}
|
||||
if (isEditing(event.target)) return;
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === 'z') {
|
||||
if (chartApi?.pendingAnchor || canUndo.value) {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key.startsWith('Arrow') && hasDrawingSelection.value) {
|
||||
event.preventDefault();
|
||||
const multiplier = event.shiftKey ? 4 : 1;
|
||||
|
|
@ -1318,8 +1484,8 @@ createApp({
|
|||
chartApi.setRthEnabled(prefs.value.enabled.rth);
|
||||
chartApi.setClickHandler(handleChartClick);
|
||||
chartApi.setToolCompleteHandler(handleToolComplete);
|
||||
chartApi.setLineChangeHandler(line => {
|
||||
nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line));
|
||||
chartApi.setLineChangeHandler((line, previous) => {
|
||||
nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line, previous));
|
||||
});
|
||||
chartApi.setDrawingColorDescriptor(drawingColorDescription);
|
||||
chartApi.setLineEndHandler(endLineHere);
|
||||
|
|
@ -1351,6 +1517,6 @@ createApp({
|
|||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption };
|
||||
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo };
|
||||
},
|
||||
}).mount('#app');
|
||||
|
|
|
|||
|
|
@ -1520,6 +1520,13 @@ class ConfluenceChart {
|
|||
else this.detachPointers('tool');
|
||||
}
|
||||
|
||||
clearPendingAnchor() {
|
||||
if (!this.pendingAnchor) return false;
|
||||
this.pendingAnchor = null;
|
||||
this.clearLinePreview();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixels between the element's left edge and the plot area.
|
||||
*
|
||||
|
|
@ -2738,9 +2745,10 @@ class ConfluenceChart {
|
|||
event.preventDefault();
|
||||
const level = this.levels.find(value => value.id === this.draggingLine.id);
|
||||
const moved = this.draggingLine.moved;
|
||||
const original = this.draggingLine.original;
|
||||
this.draggingLine = null;
|
||||
this.detachPointers('line');
|
||||
if (moved && level && this.onLineChange) this.onLineChange({ ...level });
|
||||
if (moved && level && this.onLineChange) this.onLineChange({ ...level }, { ...original });
|
||||
}
|
||||
|
||||
startAnchorDrag(event, anchor) {
|
||||
|
|
@ -2750,6 +2758,7 @@ class ConfluenceChart {
|
|||
this.draggingAnchor = {
|
||||
id: this.selectedLineId,
|
||||
anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor,
|
||||
original: level ? { ...level } : null,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
this.attachPointers('anchor');
|
||||
|
|
@ -2802,11 +2811,19 @@ class ConfluenceChart {
|
|||
finishAnchorDrag(event) {
|
||||
if (!this.draggingAnchor) return;
|
||||
event.preventDefault();
|
||||
const original = this.draggingAnchor.original;
|
||||
const level = this.handleLevel(this.draggingAnchor.id);
|
||||
this.draggingAnchor = null;
|
||||
this.detachPointers('anchor');
|
||||
this.hideSnapDot();
|
||||
if (level && this.onLineChange) this.onLineChange({ ...level });
|
||||
if (level && original && this.onLineChange && (
|
||||
level.anchor_t !== original.anchor_t
|
||||
|| level.anchor_p !== original.anchor_p
|
||||
|| level.slope !== original.slope
|
||||
|| level.last_t !== original.last_t
|
||||
)) {
|
||||
this.onLineChange({ ...level }, { ...original });
|
||||
}
|
||||
}
|
||||
|
||||
showContextMenu(event) {
|
||||
|
|
@ -2845,23 +2862,25 @@ class ConfluenceChart {
|
|||
endSelectedLineHere() {
|
||||
const level = this.levels.find(value => value.id === this.selectedLineId);
|
||||
if (!level || this.contextCutoff == null) return;
|
||||
const previousCutoff = level.cutoff_t;
|
||||
level.cutoff_t = this.contextCutoff;
|
||||
const entry = this.levelSeries.get(level.id);
|
||||
if (entry) entry.series.setData(this.seriesLineData(level));
|
||||
this.renderLineBridges();
|
||||
this.hideContextMenu();
|
||||
if (this.onLineEnd) this.onLineEnd({ ...level });
|
||||
if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff);
|
||||
}
|
||||
|
||||
extendSelectedLine() {
|
||||
const level = this.levels.find(value => value.id === this.selectedLineId);
|
||||
if (!level || level.cutoff_t == null) return;
|
||||
const previousCutoff = level.cutoff_t;
|
||||
level.cutoff_t = null;
|
||||
const entry = this.levelSeries.get(level.id);
|
||||
if (entry) entry.series.setData(this.seriesLineData(level));
|
||||
this.renderLineBridges();
|
||||
this.hideContextMenu();
|
||||
if (this.onLineEnd) this.onLineEnd({ ...level });
|
||||
if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff);
|
||||
}
|
||||
|
||||
// Tinted by the bar's own direction, muted so the candles stay the subject.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@
|
|||
<section class="chart-shell">
|
||||
<div class="chart-head">
|
||||
<div class="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></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="chart-head-tools">
|
||||
<button type="button" class="undo-btn" :disabled="!canUndo" :title="undoTitle" aria-label="Undo" @click="undo"><i class="fa-solid fa-rotate-left"></i></button>
|
||||
<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" @dragover.prevent @drop.prevent="dropSymbol"></div>
|
||||
<div class="statusbar">
|
||||
|
|
@ -44,6 +47,7 @@
|
|||
<summary>Layers</summary>
|
||||
<details class="layer-group ma-fold" open>
|
||||
<summary>
|
||||
<i class="fa-solid fa-chevron-down ma-fold-caret" aria-hidden="true"></i>
|
||||
<label class="master" @click.stop><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
||||
</summary>
|
||||
<div class="ma-rows">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
|||
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
|
||||
.quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); }
|
||||
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
|
||||
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||
.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
||||
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
||||
.current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } }
|
||||
|
|
@ -36,7 +36,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
|||
.color-popover { grid-template-columns:1fr; width:178px; gap:3px; }
|
||||
.color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); }
|
||||
.level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }
|
||||
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 4px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); }
|
||||
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-fold-caret { width:10px; color:var(--muted); font-size:9px; transition:transform .15s; }.ma-fold[open] > summary .ma-fold-caret { transform:rotate(180deg); }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 22px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); }
|
||||
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
||||
|
||||
/* Tool palette: the head arms the tool, the body configures what it creates. */
|
||||
|
|
|
|||
|
|
@ -162,3 +162,49 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path):
|
|||
|
||||
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
|
||||
assert cleared["cutoff_t"] is None
|
||||
|
||||
|
||||
def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.routes import router
|
||||
from app.config import Settings
|
||||
from app.runtime import Runtime
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
||||
client = TestClient(app)
|
||||
created = client.post("/api/lines", json={
|
||||
"tf": "1m", "side": "support",
|
||||
"anchor_t": 100, "anchor_p": 1.0,
|
||||
"end_t": 200, "end_p": 2.0,
|
||||
"note": "keep me",
|
||||
}).json()
|
||||
line_id, number = created["id"], created["number"]
|
||||
assert client.delete(f"/api/lines/{line_id}").status_code == 204
|
||||
|
||||
restored = client.post("/api/lines/restore", json={
|
||||
"id": line_id,
|
||||
"tf": "1m",
|
||||
"side": "support",
|
||||
"anchor_t": 100,
|
||||
"anchor_p": 1.0,
|
||||
"slope": 0.01,
|
||||
"last_t": 200,
|
||||
"note": "keep me",
|
||||
"number": number,
|
||||
}).json()
|
||||
assert restored["id"] == line_id
|
||||
assert restored["number"] == number
|
||||
assert client.post("/api/lines/restore", json={
|
||||
"id": line_id,
|
||||
"tf": "1m",
|
||||
"side": "support",
|
||||
"anchor_t": 100,
|
||||
"anchor_p": 1.0,
|
||||
"slope": 0.01,
|
||||
"last_t": 200,
|
||||
"number": number,
|
||||
}).status_code == 409
|
||||
|
|
|
|||
Loading…
Reference in a new issue