diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 56ea712..41abb69 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -57,7 +57,8 @@ class ManualLine: def default_label(self) -> str: if self.horizontal: 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: return Level( diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index b181b1c..efbaa2d 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -1,6 +1,6 @@ # 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, measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`. diff --git a/docs/feature_undo.md b/docs/feature_undo.md new file mode 100644 index 0000000..193b173 --- /dev/null +++ b/docs/feature_undo.md @@ -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. diff --git a/static/app.js b/static/app.js index 7a1e4b1..3b91922 100644 --- a/static/app.js +++ b/static/app.js @@ -366,7 +366,8 @@ createApp({ const temporaryId = `tmp_${Date.now()}`; const optimistic = { 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), points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false, color: drawColor.value, line_width: drawWidth.value, diff --git a/static/chart.js b/static/chart.js index 34e1ff3..f8f0f0f 100644 --- a/static/chart.js +++ b/static/chart.js @@ -8,6 +8,7 @@ class ConfluenceChart { this.levelSeries = new Map(); this.priceLines = new Map(); this.previewLine = null; + this.projectionLayer = null; this.bars = []; this.levels = []; this.onChartClick = null; @@ -169,6 +170,7 @@ class ConfluenceChart { requestAnimationFrame(() => { this.observePlotCanvas(); this.renderAnchorHandles(); + this.renderTrendlineProjections(); this.renderComments(); this.renderContextLabels(); }); @@ -196,6 +198,10 @@ class ConfluenceChart { this.previewLine.setAttribute('hidden', ''); preview.appendChild(this.previewLine); 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'); handles.classList.add('chart-handles'); handles.setAttribute('aria-hidden', 'true'); @@ -317,6 +323,7 @@ class ConfluenceChart { requestAnimationFrame(() => this.observePlotCanvas()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.renderAnchorHandles(); + this.renderTrendlineProjections(); this.renderComments(); this.renderContextLabels(); }); @@ -347,6 +354,7 @@ class ConfluenceChart { // The bar grid just changed underneath every pinned comment. this.renderComments(); this.renderContextLabels(); + this.renderTrendlineProjections(); } updateBar(bar) { @@ -362,6 +370,7 @@ class ConfluenceChart { else this.bars.push(bar); this.renderAnchorHandles(); this.renderContextLabels(); + this.renderTrendlineProjections(); } setComments(comments) { @@ -498,6 +507,14 @@ class ConfluenceChart { 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 // span the whole chart regardless of scroll and get a price-axis label. syncPriceLines(levels) { @@ -516,7 +533,7 @@ class ConfluenceChart { lineWidth: level.line_width || 1, lineStyle: LightweightCharts.LineStyle.Dashed, axisLabelVisible: level.kind !== 'horizontal', - title: ConfluenceChart.isContextLevel(level) ? '' : level.label, + title: this.seriesTitle(level), }; const existing = this.priceLines.get(level.id); if (existing) existing.line.applyOptions(options); @@ -616,7 +633,7 @@ class ConfluenceChart { data.push({ time: latestTime, value: latestValue }); } } else { - data = this.lineData(level); + data = level.kind === 'manual' ? this.seriesLineData(level) : this.lineData(level); } entry.series.setData(data); } @@ -631,6 +648,7 @@ class ConfluenceChart { this.pendingView = null; } this.renderAnchorHandles(); + this.renderTrendlineProjections(); this.renderContextLabels(); } @@ -734,6 +752,7 @@ class ConfluenceChart { this.plotResizeObserver = new ResizeObserver(() => { this.syncOverlayLayer(); this.renderAnchorHandles(); + this.renderTrendlineProjections(); this.renderComments(); this.renderContextLabels(); if (this.armedTool) this.renderGesture(); @@ -764,7 +783,38 @@ class ConfluenceChart { const price = this.candles.coordinateToPrice(y); if (price == null) return null; 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) { @@ -801,7 +851,7 @@ class ConfluenceChart { showSnapDot(snapped, point = {}) { 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); if (x == null || y == null) return this.hideSnapDot(); this.snapDot.hidden = false; @@ -993,6 +1043,11 @@ class ConfluenceChart { const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x)); const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null }; 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 // rather than measuring against undefined. if (point.y == null) return base; @@ -1062,6 +1117,10 @@ class ConfluenceChart { setSelectedLine(id) { this.selectedLineId = id; 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(); } @@ -1098,21 +1157,28 @@ class ConfluenceChart { hitTest(param) { let best = null; + const visible = this.visibleBarIndexes(); + if (!visible) return null; // Flat levels draw as price lines, which have no series geometry to hit — // they are selected and removed from the sidebar list instead. for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) { - const points = this.lineData(level).map(point => ({ - x: this.chart.timeScale().timeToCoordinate(point.time), - y: this.candles.priceToCoordinate(point.value), - })); - for (let index = 1; index < points.length; index += 1) { - const [start, end] = [points[index - 1], points[index]]; - 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; - 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); - if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; - } + const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); + const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); + const endIndex = Math.min(naturalEnd, visible.to); + if (endIndex <= startIndex) continue; + const start = { + 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 (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 distance = Math.abs(lineY - param.point.y); + if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } return best?.id || null; } @@ -1157,6 +1223,71 @@ class ConfluenceChart { 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) { // Interpolated across bars, matching what the server scores the level at. // Doing it per second instead kicked the line upward at every session gap, @@ -1190,11 +1321,19 @@ class ConfluenceChart { this.lineHitTarget?.setAttribute('hidden', ''); return; } - const data = this.lineData(level); - const points = data.slice(0, 2); - const hitPoints = [data[0], data[data.length - 1]].map(point => ({ - x: this.chart.timeScale().timeToCoordinate(point.time), - y: this.candles.priceToCoordinate(point.value), + const points = [ + { time: level.anchor_t, value: level.anchor_p }, + { + time: level.last_t, + 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)) { this.lineHitTarget.setAttribute('hidden', ''); @@ -1206,7 +1345,7 @@ class ConfluenceChart { this.lineHitTarget.setAttribute('y2', hitPoints[1].y); } 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 handle = this.anchorHandles[index]; if (x == null || y == null) { @@ -1258,8 +1397,9 @@ class ConfluenceChart { } this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; 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.renderTrendlineProjections(); } finishLineDrag(event) { @@ -1305,8 +1445,9 @@ class ConfluenceChart { level.slope = (price - level.anchor_p) / (time - level.anchor_t); } 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.renderTrendlineProjections(); } finishAnchorDrag(event) { @@ -1349,7 +1490,8 @@ class ConfluenceChart { if (!level || this.contextCutoff == null) return; level.cutoff_t = this.contextCutoff; 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(); if (this.onLineEnd) this.onLineEnd({ ...level }); } diff --git a/static/style.css b/static/style.css index af03ef7..b36e640 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } .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; }.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; } .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; } diff --git a/tests/e2e/price-scale.test.mjs b/tests/e2e/price-scale.test.mjs index 68b050b..0c7f058 100644 --- a/tests/e2e/price-scale.test.mjs +++ b/tests/e2e/price-scale.test.mjs @@ -5,6 +5,16 @@ import { withChart, assertNoPageErrors } from './helpers.mjs'; test('every price overlay shares the candle price scale', { timeout: 180000 }, async () => { 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 c = window.__chart; const series = [...c.levelSeries.entries()].map(([id, entry]) => ({ diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 1dea3f6..7ccb220 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -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', { timeout: 180000 }, async () => { await withChart(async page => { @@ -204,7 +301,7 @@ test('the line context menu duplicates by ten bars and deletes through the norma }, original); assert.equal(duplicated.anchorShift, 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(duplicated.line.label, `${expectedName} 2`); assert.equal(duplicated.line.color, original.color); diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index c18d921..b950a22 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -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 ) - assert resistance.default_label() == "down" - assert support.default_label() == "up" + assert resistance.default_label() == "down1h" + assert support.default_label() == "up1m" def test_manual_line_raises_existing_ma_cluster_score():