better default names
This commit is contained in:
parent
3b3c06a1f3
commit
e02f919b27
9 changed files with 634 additions and 32 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
351
docs/feature_undo.md
Normal file
351
docs/feature_undo.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
182
static/chart.js
182
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,22 +1157,29 @@ 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]];
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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]) => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue