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:
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
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 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,
|
||||||
|
|
|
||||||
192
static/chart.js
192
static/chart.js
|
|
@ -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,21 +1157,28 @@ 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),
|
||||||
if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue;
|
y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)),
|
||||||
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 end = {
|
||||||
const distance = Math.abs(lineY - param.point.y);
|
x: this.coordinateAtIndex(endIndex),
|
||||||
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
|
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;
|
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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]) => ({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue