better default names

This commit is contained in:
Chris Amow 2026-08-11 14:36:58 -05:00
parent 3b3c06a1f3
commit e02f919b27
9 changed files with 634 additions and 32 deletions

View file

@ -57,7 +57,8 @@ class ManualLine:
def default_label(self) -> str: def default_label(self) -> str:
if self.horizontal: if self.horizontal:
return f"@ {self.anchor_p:.2f}" return f"@ {self.anchor_p:.2f}"
return "up" if self.side is Side.SUPPORT else "down" direction = "up" if self.side is Side.SUPPORT else "down"
return f"{direction}{self.tf.value}"
def to_level(self) -> Level: def to_level(self) -> Level:
return Level( return Level(

View file

@ -1,6 +1,6 @@
# Current recommendations # Current recommendations
Last reviewed: 2026-08-11 04:13 CDT. Last reviewed: 2026-08-11 05:58 CDT.
This file is the short list of work worth considering next. Verified history, This file is the short list of work worth considering next. Verified history,
measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`. measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`.

351
docs/feature_undo.md Normal file
View file

@ -0,0 +1,351 @@
# Undo and Redo
Status: proposed.
## Goal
Add reliable Undo and Redo for user-authored drawings without silently
overwriting newer changes, changing drawing identity, or allowing REST and
WebSocket ordering to corrupt the result.
The durable design is a server-authoritative drawing command journal with a
frontend command stack. This is deliberately smaller than full event sourcing:
current drawing state remains directly persisted, while a bounded journal keeps
the before/after snapshots needed to reverse accepted user commands.
## Scope
Undoable persistent operations:
- create and duplicate a trendline;
- move a whole trendline or either anchor;
- end a trendline at a cutoff;
- rename, recolor, resize, arm, or re-arm a drawing;
- create or edit a typed price level;
- create, edit, recolor, pin, float, move, or delete a comment;
- delete one drawing, a selection, or all drawings matching a filter.
Transient behavior:
- If a trendline has a pending first anchor, Undo cancels that anchor before it
touches persistent history. This is not written to the command journal.
- Escape should cancel a pending anchor/tool/context menu without creating an
undo entry.
Excluded:
- MA, VWAP, prior-day and other derived levels;
- chart viewport, timeframe and layer preferences;
- selection and expanded/collapsed sidebar sections;
- automatic alert disarming performed by the runtime;
- notifications that have already been sent.
Comment collapse/expand should initially remain outside command history. It is
persisted display state, but including every toggle would make useful history
hard to reach.
## Why Frontend-Only Undo Is Insufficient
The frontend knows which gesture occurred, but the current CRUD endpoints do
not provide enough guarantees to invert it safely:
- Recreating a deleted drawing through a create endpoint produces a new ID,
number and creation time. IDs also affect cluster identity.
- `ManualLineStore` has no revisions, command IDs, transactions across multiple
drawings, or history.
- Drawing numbers are derived from the current maximum and can be reused after
deleting the highest-numbered drawing.
- REST responses and WebSocket level deltas can arrive in either order.
- Comments are fetched separately and are not synchronized through WebSocket
drawing deltas.
- Optimistic drag/delete failures currently have no general rollback path.
- A stale inverse from one tab could overwrite a newer edit from another tab.
- Filtered bulk deletion is a series of requests and can partially succeed.
Undo therefore needs one atomic backend command boundary. The frontend remains
responsible for interaction, labels and optimistic rendering, but the server
decides whether a command or inverse is still valid.
## Semantics
1. Undo reverses the most recent accepted command made by the current browser
actor, not blindly the most recent global mutation.
2. Redo reapplies the command that Undo reversed.
3. Any new forward command clears that actor's redo stack.
4. Undo and Redo preserve every persisted drawing field, including ID, drawing
number, creation time, geometry, cutoff, style, comment state and `armed`.
5. A completed drag is one command, regardless of how many pointer-move frames
were rendered.
6. A multi-selection or filtered deletion is one atomic command and one history
entry.
7. Undo conflicts rather than overwriting a drawing changed by another command
after the target command completed.
8. Undo restores drawing state only. It cannot retract a browser/ntfy alert or
reverse historical market evaluation.
9. History is initially bounded and may be cleared by a server restart. Durable
cross-deploy history belongs with the eventual SQLite persistence stage.
Suggested initial limits are 100 commands or 24 hours, whichever is reached
first. Limits should be configuration, not part of command correctness.
## Command Model
Each accepted mutation produces a command record:
```text
DrawingCommand
command_id client-generated UUID used for idempotency
actor_id stable random ID for one browser profile
action create, patch, delete, bulk_delete, undo, redo
target_ids affected drawing IDs
expected target revisions supplied by the client
before complete ManualLine snapshots before mutation
after complete ManualLine snapshots after mutation
store_revision monotonically increasing drawing-store revision
created_at
undone_by inverse command ID or null
redone_by redo command ID or null
```
The complete snapshot is the persisted `ManualLine` state:
```text
id, tf, side, anchor_t, anchor_p, slope, last_t, created_at,
note, hidden, color, line_width, number, cutoff_t, armed,
kind, pinned, x, y, collapsed, revision
```
The server must assign a monotonically increasing drawing number from persisted
store metadata. It must not recalculate the next number from only the currently
existing drawings.
### Revisions and Conflicts
Each drawing receives a revision. A command that changes existing drawings
includes their expected revisions. The backend rejects the complete command
with `409 Conflict` if any expected revision is stale.
Undo carries the revisions produced by the original command. If a later command
changed one of those drawings, Undo is rejected rather than restoring a stale
full snapshot. The UI should retain the failed command in history and explain
that a newer change prevents undoing it.
### Idempotency
Create and retry behavior must use `command_id`. Repeating an accepted command
returns its original receipt instead of creating a second drawing. This matters
when persistence succeeds but a response, rebuild, or network connection fails.
## Backend Architecture
Introduce a `DrawingService` above `ManualLineStore`. All drawing create, patch,
delete, restore and batch operations go through it.
Responsibilities:
1. Acquire the drawing-store lock.
2. Reject a duplicate `command_id` or return its existing receipt.
3. Validate expected revisions.
4. Capture complete `before` snapshots.
5. Build and validate the complete proposed `after` state.
6. Apply all affected records atomically and save once.
7. Append the command record and increment the store revision.
8. Rebuild levels once when level-bearing drawings changed.
9. Broadcast one drawing delta and one resulting cluster update.
10. Return the same command receipt used by WebSocket reconciliation.
The JSON implementation can write drawing state, store metadata and the bounded
journal together through the existing temporary-file-and-replace pattern. A
later SQLite implementation should preserve the service and API contracts while
moving state and journal writes into one database transaction.
### Required Persistence Corrections
- Add explicit restore/upsert that preserves ID, number and creation time.
- Persist `next_number`, drawing revisions and store revision.
- Support atomic multi-record mutation and one save.
- Change PATCH processing from `exclude_none=True` to `exclude_unset=True` so
an inverse can explicitly restore `cutoff_t` to `null`.
- Validate complete resulting geometry, not only supplied PATCH fields.
- Keep comments excluded from levels, clustering and alert evaluation.
## API Shape
Exact route names can follow the existing API style, but mutation responses
need a common command receipt.
```json
{
"command_id": "uuid",
"action": "move",
"store_revision": 42,
"changed": [{ "id": "ml_...", "revision": 8 }],
"removed": [],
"undo_label": "Move up 2"
}
```
Recommended operations:
```text
POST /api/drawing-commands execute create/patch/delete/batch
POST /api/drawing-commands/{id}/undo atomically apply the inverse
POST /api/drawing-commands/{id}/redo atomically reapply the result
GET /api/drawing-commands?actor_id= recover bounded own history
```
Existing drawing routes can initially become adapters that call
`DrawingService`, but new frontend work should use the command endpoint so every
mutation has idempotency, revisions and a receipt.
## WebSocket Synchronization
Replace line-only assumptions with a drawing delta that covers trendlines,
price levels and comments:
```json
{
"type": "drawings",
"seq": 42,
"command_id": "uuid",
"actor_id": "browser-id",
"changed": ["complete drawing DTOs"],
"removed": ["ml_..."]
}
```
The initial snapshot should include all drawings or the current drawing
revision plus a required refetch. Every drawing delta has a monotonically
increasing sequence. If a client detects a gap, it refetches a complete drawing
snapshot instead of applying uncertain incremental state.
The frontend reconciles REST acknowledgement and WebSocket delivery by
`command_id`; receiving both must not apply a command twice. Drawing removal
also clears nonexistent IDs from single and multi-selection state.
## Frontend Command Manager
Add one command manager in `static/app.js` rather than separate undo logic in
each control:
```text
execute(command, optimisticProjection)
undo()
redo()
canUndo
canRedo
undoLabel
redoLabel
```
Execution flow:
1. Capture the relevant current revisions and before state.
2. Apply an optimistic projection when useful for interaction.
3. Send a command with a UUID and actor ID.
4. Commit it to the local Undo stack only after server acknowledgement.
5. Roll back the optimistic projection on failure.
6. Reconcile the matching WebSocket command receipt.
Commands should serialize per drawing. Completed drag operations should
coalesce into one PATCH/command on pointer release; pointer movement remains
local rendering only. Repeated style changes may be coalesced later, but are not
required for the first release.
## Controls and Shortcuts
- `Ctrl+Z` and `Cmd+Z`: Undo.
- `Ctrl+Shift+Z` and `Cmd+Shift+Z`: Redo.
- `Ctrl+Y`: Redo on platforms where it is conventional.
- Keep the existing input/editing guard so text fields retain native Undo.
- If a first trendline anchor is pending, Undo cancels it first.
- Call `preventDefault()` only when the chart actually handled the shortcut.
- Add visible Undo and Redo buttons with labels/tooltips such as
`Undo move up 2`; keyboard shortcuts cannot be the only mobile access.
- Disable controls while an Undo/Redo request is pending.
Selection itself is not undoable. Undoing deletion may select the single
restored drawing, but batch restoration should leave selection empty unless a
clear product rule is chosen.
## Failure Handling
- Network/server failure: restore the optimistic before state and show a visible
error; do not add a history entry.
- Revision conflict: refetch drawings, preserve the failed history entry, and
explain that a newer change prevents Undo.
- Missed WebSocket sequence: refetch the complete drawing snapshot.
- Partial bulk failure: impossible by contract; the whole command commits or
none of it does.
- Rebuild failure after persistence: return the stored idempotent receipt on
retry and recover derived levels from authoritative drawing state.
- Session expiration: authentication may retry, but the same `command_id` must
be reused so a create cannot duplicate.
## Rollout
### Stage 1: Reliable Command Boundary
- Add `DrawingService`, revisions, stable restore, monotonic numbering,
idempotent command IDs and atomic batches.
- Route all drawing mutations through it.
- Return common command receipts.
- Add in-memory/bounded server journal and frontend Undo/Redo stacks.
- Implement create, patch, move, delete and bulk-delete inverses.
- Add shortcuts and visible controls.
Stage 1 history may clear on restart, but every command during the process
lifetime must be safe and conflict-aware.
### Stage 2: Complete Multi-Client Synchronization
- Broadcast full drawing deltas, including comments.
- Add sequence-gap recovery and complete drawing snapshots.
- Reconcile REST and WebSocket acknowledgements by command ID.
- Clean stale selection state after remote mutations.
### Stage 3: Durable History
- Move drawing state and the bounded command journal into SQLite.
- Preserve command/API contracts.
- Retain history by count/time policy across restart and deployment.
- Add audit-only, non-undoable records for automatic runtime changes if useful.
## Tests That Earn Their Place
Backend:
1. Undo delete restores every field with the same ID and drawing number.
2. Undo end-here restores `cutoff_t` to `null`.
3. Bulk delete is all-or-nothing and saves/rebuilds/broadcasts once.
4. Retrying one `command_id` cannot create a second drawing.
5. Undo rejects a stale revision after another actor edits the drawing.
6. Redo restores the exact accepted result; a new command clears redo.
7. Comment commands synchronize but never enter levels or confluence.
8. Automatic alert disarm does not enter user Undo history.
Browser:
1. Create, Undo and Redo preserve the drawing ID.
2. Whole-line and anchor drags Undo to exact prior geometry.
3. Single and filtered batch deletion restore the complete set.
4. Pending first anchor consumes Undo before persistent history.
5. Undo inside a text field remains native text editing.
6. A conflict displays an error and does not overwrite newer state.
7. A second tab observes drawing and Undo deltas, including comments.
8. Touch-accessible controls expose the same command labels and states.
## Decisions to Confirm Before Implementation
- Whether bounded history should survive a normal server restart in Stage 1 or
wait for SQLite in Stage 3.
- Whether persisted comment collapse/expand should remain excluded.
- Whether undoing one deleted drawing should select the restored drawing.
- The initial history count/time limits.
- Whether actor identity remains per browser profile or later becomes the
authenticated user ID. The command model supports either.
The first implementation task is the atomic, revisioned `DrawingService`, not
the Undo button. Building the controls first would make deletion restoration,
bulk actions and cross-tab edits appear to work while retaining silent data-loss
races.

View file

@ -366,7 +366,8 @@ createApp({
const temporaryId = `tmp_${Date.now()}`; const temporaryId = `tmp_${Date.now()}`;
const optimistic = { const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side, id: temporaryId, kind: 'manual', tf: timeframe.value, side,
weight: 1, score: 1, label: drawName.value || (side === 'support' ? 'up' : 'down'), weight: 1, score: 1,
label: drawName.value || `${side === 'support' ? 'up' : 'down'}${timeframe.value}`,
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t), anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false, points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
color: drawColor.value, line_width: drawWidth.value, color: drawColor.value, line_width: drawWidth.value,

View file

@ -8,6 +8,7 @@ class ConfluenceChart {
this.levelSeries = new Map(); this.levelSeries = new Map();
this.priceLines = new Map(); this.priceLines = new Map();
this.previewLine = null; this.previewLine = null;
this.projectionLayer = null;
this.bars = []; this.bars = [];
this.levels = []; this.levels = [];
this.onChartClick = null; this.onChartClick = null;
@ -169,6 +170,7 @@ class ConfluenceChart {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.observePlotCanvas(); this.observePlotCanvas();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
}); });
@ -196,6 +198,10 @@ class ConfluenceChart {
this.previewLine.setAttribute('hidden', ''); this.previewLine.setAttribute('hidden', '');
preview.appendChild(this.previewLine); preview.appendChild(this.previewLine);
this.overlayLayer.appendChild(preview); this.overlayLayer.appendChild(preview);
this.projectionLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.projectionLayer.classList.add('chart-projections');
this.projectionLayer.setAttribute('aria-hidden', 'true');
this.overlayLayer.appendChild(this.projectionLayer);
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
handles.classList.add('chart-handles'); handles.classList.add('chart-handles');
handles.setAttribute('aria-hidden', 'true'); handles.setAttribute('aria-hidden', 'true');
@ -317,6 +323,7 @@ class ConfluenceChart {
requestAnimationFrame(() => this.observePlotCanvas()); requestAnimationFrame(() => this.observePlotCanvas());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
}); });
@ -347,6 +354,7 @@ class ConfluenceChart {
// The bar grid just changed underneath every pinned comment. // The bar grid just changed underneath every pinned comment.
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
this.renderTrendlineProjections();
} }
updateBar(bar) { updateBar(bar) {
@ -362,6 +370,7 @@ class ConfluenceChart {
else this.bars.push(bar); else this.bars.push(bar);
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderContextLabels(); this.renderContextLabels();
this.renderTrendlineProjections();
} }
setComments(comments) { setComments(comments) {
@ -498,6 +507,14 @@ class ConfluenceChart {
return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal'; return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal';
} }
seriesTitle(level) {
if (ConfluenceChart.isContextLevel(level)) return '';
if (level.kind === 'manual' && !ConfluenceChart.isFlat(level)) {
return level.id === this.selectedLineId ? level.label : `#${level.number}`;
}
return level.label;
}
// Flat levels are drawn as price lines rather than two-point series: they // Flat levels are drawn as price lines rather than two-point series: they
// span the whole chart regardless of scroll and get a price-axis label. // span the whole chart regardless of scroll and get a price-axis label.
syncPriceLines(levels) { syncPriceLines(levels) {
@ -516,7 +533,7 @@ class ConfluenceChart {
lineWidth: level.line_width || 1, lineWidth: level.line_width || 1,
lineStyle: LightweightCharts.LineStyle.Dashed, lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: level.kind !== 'horizontal', axisLabelVisible: level.kind !== 'horizontal',
title: ConfluenceChart.isContextLevel(level) ? '' : level.label, title: this.seriesTitle(level),
}; };
const existing = this.priceLines.get(level.id); const existing = this.priceLines.get(level.id);
if (existing) existing.line.applyOptions(options); if (existing) existing.line.applyOptions(options);
@ -616,7 +633,7 @@ class ConfluenceChart {
data.push({ time: latestTime, value: latestValue }); data.push({ time: latestTime, value: latestValue });
} }
} else { } else {
data = this.lineData(level); data = level.kind === 'manual' ? this.seriesLineData(level) : this.lineData(level);
} }
entry.series.setData(data); entry.series.setData(data);
} }
@ -631,6 +648,7 @@ class ConfluenceChart {
this.pendingView = null; this.pendingView = null;
} }
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderContextLabels(); this.renderContextLabels();
} }
@ -734,6 +752,7 @@ class ConfluenceChart {
this.plotResizeObserver = new ResizeObserver(() => { this.plotResizeObserver = new ResizeObserver(() => {
this.syncOverlayLayer(); this.syncOverlayLayer();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
if (this.armedTool) this.renderGesture(); if (this.armedTool) this.renderGesture();
@ -764,7 +783,38 @@ class ConfluenceChart {
const price = this.candles.coordinateToPrice(y); const price = this.candles.coordinateToPrice(y);
if (price == null) return null; if (price == null) return null;
const time = this.chart.timeScale().coordinateToTime(x); const time = this.chart.timeScale().coordinateToTime(x);
return { x, y, p: price, t: time == null ? null : Number(time) }; const index = time == null ? this.indexAtCoordinate(x) : null;
const projectedTime = index == null ? null : this.timeAtIndex(index);
return { x, y, p: price, t: time == null ? projectedTime : Number(time) };
}
indexAtCoordinate(x) {
if (!this.bars.length) return null;
const scale = this.chart.timeScale();
const logical = scale.coordinateToLogical(x);
const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t);
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
if (logical == null || lastLogical == null) return null;
return this.bars.length - 1 + Number(logical) - Number(lastLogical);
}
coordinateAtIndex(index) {
if (!this.bars.length) return null;
const scale = this.chart.timeScale();
const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t);
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
if (lastLogical == null) return null;
return scale.logicalToCoordinate(Number(lastLogical) + index - (this.bars.length - 1));
}
coordinateAtTime(time) {
const last = this.bars[this.bars.length - 1];
if (!last) return null;
if (time <= last.t) {
const direct = this.chart.timeScale().timeToCoordinate(time);
if (direct != null) return direct;
}
return this.coordinateAtIndex(this.indexAt(time));
} }
startToolGesture(event) { startToolGesture(event) {
@ -801,7 +851,7 @@ class ConfluenceChart {
showSnapDot(snapped, point = {}) { showSnapDot(snapped, point = {}) {
if (snapped.t == null) return this.hideSnapDot(); if (snapped.t == null) return this.hideSnapDot();
const x = this.chart.timeScale().timeToCoordinate(snapped.t); const x = this.coordinateAtTime(snapped.t);
const y = this.candles.priceToCoordinate(snapped.p); const y = this.candles.priceToCoordinate(snapped.p);
if (x == null || y == null) return this.hideSnapDot(); if (x == null || y == null) return this.hideSnapDot();
this.snapDot.hidden = false; this.snapDot.hidden = false;
@ -993,6 +1043,11 @@ class ConfluenceChart {
const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x)); const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x));
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null }; const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
const last = this.bars[this.bars.length - 1];
if (fallbackT > last.t) {
const index = Math.round(this.indexAt(fallbackT));
return { t: this.timeAtIndex(index), p: ConfluenceChart.snapToTick(point.p), snappedSide: null };
}
// An already-snapped point carries no cursor position; return it untouched // An already-snapped point carries no cursor position; return it untouched
// rather than measuring against undefined. // rather than measuring against undefined.
if (point.y == null) return base; if (point.y == null) return base;
@ -1062,6 +1117,10 @@ class ConfluenceChart {
setSelectedLine(id) { setSelectedLine(id) {
this.selectedLineId = id; this.selectedLineId = id;
this.hideContextMenu(); this.hideContextMenu();
for (const level of this.levels.filter(value =>
value.kind === 'manual' && !ConfluenceChart.isFlat(value))) {
this.levelSeries.get(level.id)?.series.applyOptions({ title: this.seriesTitle(level) });
}
this.renderAnchorHandles(); this.renderAnchorHandles();
} }
@ -1098,22 +1157,29 @@ class ConfluenceChart {
hitTest(param) { hitTest(param) {
let best = null; let best = null;
const visible = this.visibleBarIndexes();
if (!visible) return null;
// Flat levels draw as price lines, which have no series geometry to hit — // Flat levels draw as price lines, which have no series geometry to hit —
// they are selected and removed from the sidebar list instead. // they are selected and removed from the sidebar list instead.
for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) {
const points = this.lineData(level).map(point => ({ const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
x: this.chart.timeScale().timeToCoordinate(point.time), const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
y: this.candles.priceToCoordinate(point.value), const endIndex = Math.min(naturalEnd, visible.to);
})); if (endIndex <= startIndex) continue;
for (let index = 1; index < points.length; index += 1) { const start = {
const [start, end] = [points[index - 1], points[index]]; x: this.coordinateAtIndex(startIndex),
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)),
};
const end = {
x: this.coordinateAtIndex(endIndex),
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, endIndex)),
};
if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue; if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue;
if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue; if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue;
const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x); const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x);
const distance = Math.abs(lineY - param.point.y); const distance = Math.abs(lineY - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
} }
}
return best?.id || null; return best?.id || null;
} }
@ -1157,6 +1223,71 @@ class ConfluenceChart {
return Math.round(bars[lower].t + fraction * (bars[lower + 1].t - bars[lower].t)); return Math.round(bars[lower].t + fraction * (bars[lower + 1].t - bars[lower].t));
} }
visibleBarIndexes() {
const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot) return null;
const from = this.indexAtCoordinate(0);
const to = this.indexAtCoordinate(plot.width);
return from == null || to == null ? null : { from, to };
}
linePriceAtIndex(level, index) {
const start = this.indexAt(level.anchor_t);
const end = this.indexAt(level.last_t);
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
return end === start
? level.anchor_p
: level.anchor_p + (endPrice - level.anchor_p) * (index - start) / (end - start);
}
seriesLineData(level) {
if (!this.bars.length) return [];
const start = this.indexAt(level.anchor_t);
const lastBar = this.bars.length - 1;
const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t);
const end = Math.min(lastBar, cutoff);
if (start > end) return [];
const anchorEnd = this.indexAt(level.last_t);
const indexes = [start];
if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd);
if (end > indexes[indexes.length - 1]) indexes.push(end);
return indexes.map(index => ({
time: this.timeAtIndex(index),
value: this.linePriceAtIndex(level, index),
}));
}
renderTrendlineProjections() {
if (!this.projectionLayer || !this.bars.length) return;
const visible = this.visibleBarIndexes();
if (!visible) return;
const lastBar = this.bars.length - 1;
const lines = this.levels
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
.map(level => {
const start = Math.max(lastBar, this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
const end = Math.min(naturalEnd, visible.to);
if (end <= start) return null;
const x1 = this.coordinateAtIndex(start);
const x2 = this.coordinateAtIndex(end);
const y1 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, start));
const y2 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, end));
if ([x1, x2, y1, y2].some(value => value == null)) return null;
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
node.dataset.lineId = level.id;
node.setAttribute('x1', x1);
node.setAttribute('y1', y1);
node.setAttribute('x2', x2);
node.setAttribute('y2', y2);
node.setAttribute('stroke', ConfluenceChart.levelColor(level));
node.setAttribute('stroke-width', level.line_width || 2);
return node;
})
.filter(Boolean);
this.projectionLayer.replaceChildren(...lines);
}
lineData(level) { lineData(level) {
// Interpolated across bars, matching what the server scores the level at. // Interpolated across bars, matching what the server scores the level at.
// Doing it per second instead kicked the line upward at every session gap, // Doing it per second instead kicked the line upward at every session gap,
@ -1190,11 +1321,19 @@ class ConfluenceChart {
this.lineHitTarget?.setAttribute('hidden', ''); this.lineHitTarget?.setAttribute('hidden', '');
return; return;
} }
const data = this.lineData(level); const points = [
const points = data.slice(0, 2); { time: level.anchor_t, value: level.anchor_p },
const hitPoints = [data[0], data[data.length - 1]].map(point => ({ {
x: this.chart.timeScale().timeToCoordinate(point.time), time: level.last_t,
y: this.candles.priceToCoordinate(point.value), value: level.anchor_p + level.slope * (level.last_t - level.anchor_t),
},
];
const visible = this.visibleBarIndexes();
const hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
const hitIndexes = [this.indexAt(level.anchor_t), hitEnd];
const hitPoints = hitIndexes.map(index => ({
x: index == null ? null : this.coordinateAtIndex(index),
y: index == null ? null : this.candles.priceToCoordinate(this.linePriceAtIndex(level, index)),
})); }));
if (hitPoints.some(point => point.x == null || point.y == null)) { if (hitPoints.some(point => point.x == null || point.y == null)) {
this.lineHitTarget.setAttribute('hidden', ''); this.lineHitTarget.setAttribute('hidden', '');
@ -1206,7 +1345,7 @@ class ConfluenceChart {
this.lineHitTarget.setAttribute('y2', hitPoints[1].y); this.lineHitTarget.setAttribute('y2', hitPoints[1].y);
} }
points.forEach((point, index) => { points.forEach((point, index) => {
const x = this.chart.timeScale().timeToCoordinate(point.time); const x = this.coordinateAtTime(point.time);
const y = this.candles.priceToCoordinate(point.value); const y = this.candles.priceToCoordinate(point.value);
const handle = this.anchorHandles[index]; const handle = this.anchorHandles[index];
if (x == null || y == null) { if (x == null || y == null) {
@ -1258,8 +1397,9 @@ class ConfluenceChart {
} }
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.lineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
} }
finishLineDrag(event) { finishLineDrag(event) {
@ -1305,8 +1445,9 @@ class ConfluenceChart {
level.slope = (price - level.anchor_p) / (time - level.anchor_t); level.slope = (price - level.anchor_p) / (time - level.anchor_t);
} }
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.lineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
} }
finishAnchorDrag(event) { finishAnchorDrag(event) {
@ -1349,7 +1490,8 @@ class ConfluenceChart {
if (!level || this.contextCutoff == null) return; if (!level || this.contextCutoff == null) return;
level.cutoff_t = this.contextCutoff; level.cutoff_t = this.contextCutoff;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.lineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderTrendlineProjections();
this.hideContextMenu(); this.hideContextMenu();
if (this.onLineEnd) this.onLineEnd({ ...level }); if (this.onLineEnd) this.onLineEnd({ ...level });
} }

View file

@ -13,7 +13,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } 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; } .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); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }

View file

@ -5,6 +5,16 @@ import { withChart, assertNoPageErrors } from './helpers.mjs';
test('every price overlay shares the candle price scale', test('every price overlay shares the candle price scale',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
await page.evaluate(() => {
const c = window.__chart;
const last = c.bars[c.bars.length - 1];
c.syncLevels([...c.levels, {
id: 'e2e:context-probe', kind: 'horizontal', tf: '1d', side: 'support',
label: 'Context probe', anchor_t: last.t, anchor_p: last.c,
slope: 0, first_t: last.t, last_t: last.t,
hidden: false, provisional: false, color: '#9fb0c4', line_width: 1,
}]);
});
const state = await page.evaluate(() => { const state = await page.evaluate(() => {
const c = window.__chart; const c = window.__chart;
const series = [...c.levelSeries.entries()].map(([id, entry]) => ({ const series = [...c.levelSeries.entries()].map(([id, entry]) => ({

View file

@ -161,6 +161,103 @@ test('dragging an anchor uses the same bar-extreme snapping as placement',
}); });
}); });
test('future whitespace accepts endpoint and whole-line movement without entering the time scale',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.35, 0.55);
const second = at(box, 0.55, 0.35);
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
await page.waitForTimeout(1200);
await page.evaluate(() => {
const scale = window.__chart.chart.timeScale();
const range = scale.getVisibleLogicalRange();
scale.setVisibleLogicalRange({ from: range.from + 20, to: range.to + 20 });
});
await page.waitForTimeout(400);
const future = await page.evaluate(() => {
const c = window.__chart;
const last = c.bars.length - 1;
const index = last + 10;
const line = c.levels.find(level => level.id === c.selectedLineId);
const plot = c.plotCanvas().getBoundingClientRect();
return {
id: line.id,
x: plot.left + c.coordinateAtIndex(index),
y: plot.top + c.candles.priceToCoordinate(c.linePriceAtIndex(line, index)),
};
});
const endHandle = await page.locator(
'.chart-anchor[data-anchor="end"]:not([hidden])').boundingBox();
await page.mouse.move(endHandle.x + endHandle.width / 2, endHandle.y + endHandle.height / 2);
await page.mouse.down();
await page.mouse.move(future.x, future.y, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(900);
const projected = await page.evaluate(id => {
const c = window.__chart;
const line = c.levels.find(level => level.id === id);
const data = c.levelSeries.get(id).series.data();
const lastBar = c.bars[c.bars.length - 1];
const projection = document.querySelector(`.chart-projections [data-line-id="${id}"]`);
return {
anchorIndex: c.indexAt(line.anchor_t),
endIndex: c.indexAt(line.last_t),
lastBarIndex: c.bars.length - 1,
maxSeriesTime: Math.max(...data.map(point => Number(point.time))),
lastBarTime: lastBar.t,
projectionEnd: Number(projection?.getAttribute('x2')),
plotWidth: c.plotCanvas().getBoundingClientRect().width,
};
}, future.id);
assert.ok(projected.endIndex > projected.lastBarIndex,
'the endpoint was clamped to the newest candle');
assert.ok(projected.maxSeriesTime <= projected.lastBarTime,
'future drawing geometry entered the shared time scale');
assert.ok(projected.projectionEnd >= projected.plotWidth - 2,
'the active line did not project through the visible whitespace');
const drag = await page.evaluate(() => {
const c = window.__chart;
const hit = c.lineHitTarget;
const plot = c.plotCanvas().getBoundingClientRect();
const x1 = Number(hit.getAttribute('x1'));
const y1 = Number(hit.getAttribute('y1'));
const x2 = Number(hit.getAttribute('x2'));
const y2 = Number(hit.getAttribute('y2'));
const spacing = c.coordinateAtIndex(c.bars.length - 1)
- c.coordinateAtIndex(c.bars.length - 2);
return {
x: plot.left + x1 + (x2 - x1) * 0.65,
y: plot.top + y1 + (y2 - y1) * 0.65,
dx: spacing * 3,
};
});
await page.mouse.move(drag.x, drag.y);
await page.mouse.down();
await page.mouse.move(drag.x + drag.dx, drag.y + 20, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(900);
const moved = await page.evaluate(id => {
const c = window.__chart;
const line = c.levels.find(level => level.id === id);
return { anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t) };
}, future.id);
assert.equal(
Math.round(moved.endIndex - projected.endIndex),
Math.round(moved.anchorIndex - projected.anchorIndex),
'the future endpoint did not translate with the rest of the line',
);
assertNoPageErrors(page, assert);
});
});
test('the line context menu duplicates by ten bars and deletes through the normal flow', test('the line context menu duplicates by ten bars and deletes through the normal flow',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
@ -204,7 +301,7 @@ test('the line context menu duplicates by ten bars and deletes through the norma
}, original); }, original);
assert.equal(duplicated.anchorShift, 10); assert.equal(duplicated.anchorShift, 10);
assert.equal(duplicated.endShift, 10); assert.equal(duplicated.endShift, 10);
const expectedName = original.side === 'support' ? 'up' : 'down'; const expectedName = `${original.side === 'support' ? 'up' : 'down'}${original.tf}`;
assert.equal(original.label, expectedName); assert.equal(original.label, expectedName);
assert.equal(duplicated.line.label, `${expectedName} 2`); assert.equal(duplicated.line.label, `${expectedName} 2`);
assert.equal(duplicated.line.color, original.color); assert.equal(duplicated.line.color, original.color);

View file

@ -37,8 +37,8 @@ def test_unnamed_trendlines_are_named_for_their_direction():
"ml_support", Timeframe.M1, Side.SUPPORT, 100, 5000, 0.01, 200, 300 "ml_support", Timeframe.M1, Side.SUPPORT, 100, 5000, 0.01, 200, 300
) )
assert resistance.default_label() == "down" assert resistance.default_label() == "down1h"
assert support.default_label() == "up" assert support.default_label() == "up1m"
def test_manual_line_raises_existing_ma_cluster_score(): def test_manual_line_raises_existing_ma_cluster_score():