Compare commits
No commits in common. "9b6cff853b9c48058513d2abf0f90cde48197662" and "a0b8ac1cd2b8f9f6fd978bc335f950bd7c02a4de" have entirely different histories.
9b6cff853b
...
a0b8ac1cd2
8 changed files with 45 additions and 605 deletions
|
|
@ -25,29 +25,6 @@ class ManualLine:
|
||||||
number: int = 0
|
number: int = 0
|
||||||
cutoff_t: int | None = None
|
cutoff_t: int | None = None
|
||||||
armed: bool = True
|
armed: bool = True
|
||||||
# "" means a line predating drawing kinds; `kind` derives it. Comments carry
|
|
||||||
# their text in `note`, like every other drawing carries its label.
|
|
||||||
kind: str = ""
|
|
||||||
pinned: bool = True
|
|
||||||
x: float = 0.72
|
|
||||||
y: float = 0.12
|
|
||||||
collapsed: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def drawing_kind(self) -> str:
|
|
||||||
"""What this drawing is, for grouping and filtering.
|
|
||||||
|
|
||||||
Derived when absent so drawings saved before comments existed keep
|
|
||||||
working: a zero slope was always a typed price level, anything else a
|
|
||||||
drawn trendline.
|
|
||||||
"""
|
|
||||||
if self.kind:
|
|
||||||
return self.kind
|
|
||||||
return "level" if self.horizontal else "trendline"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_comment(self) -> bool:
|
|
||||||
return self.drawing_kind == "comment"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def horizontal(self) -> bool:
|
def horizontal(self) -> bool:
|
||||||
|
|
@ -108,11 +85,6 @@ class ManualLine:
|
||||||
number=int(value.get("number", 0)),
|
number=int(value.get("number", 0)),
|
||||||
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
|
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
|
||||||
armed=bool(value.get("armed", True)),
|
armed=bool(value.get("armed", True)),
|
||||||
kind=str(value.get("kind", "")),
|
|
||||||
pinned=bool(value.get("pinned", True)),
|
|
||||||
x=float(value.get("x", 0.72)),
|
|
||||||
y=float(value.get("y", 0.12)),
|
|
||||||
collapsed=bool(value.get("collapsed", False)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -174,15 +146,4 @@ class ManualLineStore:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def levels(self) -> list[Level]:
|
def levels(self) -> list[Level]:
|
||||||
"""Only the drawings that are levels.
|
return [line.to_level() for line in self.lines.values()]
|
||||||
|
|
||||||
Comments are stored alongside lines so they share numbering, filtering
|
|
||||||
and deletion, but they are annotations. Letting one through here would
|
|
||||||
put it in a confluence cluster and fire a push notification about a
|
|
||||||
piece of text.
|
|
||||||
"""
|
|
||||||
return [line.to_level() for line in self.lines.values() if not line.is_comment]
|
|
||||||
|
|
||||||
def drawings(self) -> list[ManualLine]:
|
|
||||||
"""Everything drawn, comments included, newest number last."""
|
|
||||||
return sorted(self.lines.values(), key=lambda line: line.number)
|
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,6 @@ class LinePatch(BaseModel):
|
||||||
last_t: int | None = None
|
last_t: int | None = None
|
||||||
cutoff_t: int | None = None
|
cutoff_t: int | None = None
|
||||||
armed: bool | None = None
|
armed: bool | None = None
|
||||||
pinned: bool | None = None
|
|
||||||
x: float | None = Field(None, ge=0.0, le=1.0)
|
|
||||||
y: float | None = Field(None, ge=0.0, le=1.0)
|
|
||||||
collapsed: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|
@ -155,58 +151,6 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
|
||||||
return line.to_level().to_dict()
|
return line.to_level().to_dict()
|
||||||
|
|
||||||
|
|
||||||
class CommentCreate(BaseModel):
|
|
||||||
text: str = Field(min_length=1, max_length=2000)
|
|
||||||
# Pinned to a moment on the chart, or floating and always on screen.
|
|
||||||
pinned: bool = True
|
|
||||||
anchor_t: int | None = None
|
|
||||||
anchor_p: float | None = None
|
|
||||||
x: float = Field(0.72, ge=0.0, le=1.0)
|
|
||||||
y: float = Field(0.12, ge=0.0, le=1.0)
|
|
||||||
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
|
|
||||||
tf: Timeframe = Timeframe.M1
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/comments", status_code=201)
|
|
||||||
def create_comment(request: Request, payload: CommentCreate):
|
|
||||||
"""A note on the chart. Stored with the lines so it shares their numbering,
|
|
||||||
filtering and deletion, but it is never a level — see ManualLineStore.levels.
|
|
||||||
"""
|
|
||||||
runtime = request.app.state.runtime
|
|
||||||
now = int(time.time())
|
|
||||||
anchored = payload.anchor_t if payload.anchor_t is not None else now
|
|
||||||
line = ManualLine(
|
|
||||||
id=f"ml_{uuid.uuid4().hex}",
|
|
||||||
tf=payload.tf,
|
|
||||||
# Side is meaningless for a comment; clustering never sees it.
|
|
||||||
side=Side.SUPPORT,
|
|
||||||
anchor_t=anchored,
|
|
||||||
anchor_p=payload.anchor_p if payload.anchor_p is not None else (runtime.price or 0.0),
|
|
||||||
slope=0.0,
|
|
||||||
last_t=anchored,
|
|
||||||
created_at=now,
|
|
||||||
note=payload.text,
|
|
||||||
color=payload.color,
|
|
||||||
kind="comment",
|
|
||||||
pinned=payload.pinned,
|
|
||||||
x=payload.x,
|
|
||||||
y=payload.y,
|
|
||||||
# A comment must never alert, whatever else changes around it.
|
|
||||||
armed=False,
|
|
||||||
)
|
|
||||||
line = runtime.manual_lines.add(line)
|
|
||||||
return line.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/drawings")
|
|
||||||
def drawings(request: Request):
|
|
||||||
"""Every drawing, comments included, for the sidebar list."""
|
|
||||||
store = request.app.state.runtime.manual_lines
|
|
||||||
return {"drawings": [
|
|
||||||
{**line.to_dict(), "kind": line.drawing_kind} for line in store.drawings()
|
|
||||||
]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/lines/{line_id}")
|
@router.patch("/lines/{line_id}")
|
||||||
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
||||||
changes = payload.model_dump(exclude_none=True)
|
changes = payload.model_dump(exclude_none=True)
|
||||||
|
|
@ -217,9 +161,7 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(404, "Line not found") from exc
|
raise HTTPException(404, "Line not found") from exc
|
||||||
request.app.state.runtime.rebuild_levels()
|
request.app.state.runtime.rebuild_levels()
|
||||||
# A comment has no level form — returning one would hand the caller a shape
|
return line.to_level().to_dict()
|
||||||
# that looks like something the confluence engine tracks.
|
|
||||||
return line.to_dict() if line.is_comment else line.to_level().to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/lines/{line_id}", status_code=204)
|
@router.delete("/lines/{line_id}", status_code=204)
|
||||||
|
|
|
||||||
|
|
@ -1425,67 +1425,3 @@ owning a row, which suits a control that is disabled until M8. The alert log
|
||||||
becomes a `<details>` like Confluence zones, closed by default with its count in
|
becomes a `<details>` like Confluence zones, closed by default with its count in
|
||||||
the summary — collapsed by default is the point, since leaving it open would
|
the summary — collapsed by default is the point, since leaving it open would
|
||||||
save nothing, and the count means activity is still visible while closed.
|
save nothing, and the count means activity is still visible while closed.
|
||||||
|
|
||||||
**Sidebar vertical space.** The right column was taller than the viewport with
|
|
||||||
nothing selected. Five changes, no functionality removed:
|
|
||||||
|
|
||||||
- The five daily MA periods fit one line (`flex-wrap:nowrap`, tighter gaps, 12px
|
|
||||||
boxes); 10px gaps and a 22px indent had pushed 200 onto a row of its own.
|
|
||||||
- Auto trendlines becomes a parenthetical `(auto)` on the Manual lines row
|
|
||||||
rather than owning one, which suits a control disabled until M8.
|
|
||||||
- Alert log becomes a `<details>` like Confluence zones, closed by default with
|
|
||||||
its count in the summary so activity still shows while shut.
|
|
||||||
- Tools becomes a `<details>` too, open by default, and each tool's panel is
|
|
||||||
bound to `armedTool` — only the armed tool shows its label, colour, width and
|
|
||||||
side controls. `armTool` already toggles and permits one armed tool at a time,
|
|
||||||
so the panels follow it exactly.
|
|
||||||
- Order is Layers, Tools, then the rest, with Layers collapsed by default.
|
|
||||||
|
|
||||||
Measured with nothing armed: 1110px of content down to 900px, which is inside
|
|
||||||
the viewport rather than past it. `.sidebar-section:first-of-type` carries the
|
|
||||||
zeroed top margin so reordering cannot reintroduce a gap at the top.
|
|
||||||
|
|
||||||
### 2026-08-10 (evening) — chart comments, and Drawings
|
|
||||||
|
|
||||||
**Comments are drawings, not levels.** A comment is stored as a `ManualLine`
|
|
||||||
with `kind="comment"`, so it inherits persistence, the shared drawing-number
|
|
||||||
sequence, the sidebar list, filtering and deletion without a parallel set of
|
|
||||||
endpoints. The one rule that must never bend: `ManualLineStore.levels()` filters
|
|
||||||
comments out. A comment reaching the level list would join a confluence cluster
|
|
||||||
and push a phone notification about a piece of text. It is also created with
|
|
||||||
`armed=False`, and `PATCH /lines/{id}` returns `to_dict()` rather than
|
|
||||||
`to_level()` for one, so no caller is ever handed a level-shaped comment.
|
|
||||||
|
|
||||||
`kind` is derived when absent — zero slope was always a typed level, anything
|
|
||||||
else a drawn trendline — so drawings saved before comments existed keep working.
|
|
||||||
|
|
||||||
**Pinned or floating.** Pinned comments carry `anchor_t`/`anchor_p` and move with
|
|
||||||
the chart; floating ones carry `x`/`y` as fractions of the pane, hold their place
|
|
||||||
through any zoom, and can be dragged. Comments render as DOM rather than canvas:
|
|
||||||
they hold arbitrary text, collapse to a numbered dot, and a floating one has to
|
|
||||||
ignore the time scale entirely. A pinned comment scrolled out of view parks on
|
|
||||||
the edge it left, pointing back the way it went, so it is never simply lost.
|
|
||||||
|
|
||||||
**"Lines & levels" becomes "Drawings"**, filtered by type and by text — the text
|
|
||||||
match covers the label, the kind and the `#number`, so `comment`, `cpi` and `7`
|
|
||||||
all narrow the list. Delete acts on what the filter shows, which is what makes
|
|
||||||
deleting by type or by string a single button.
|
|
||||||
|
|
||||||
One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured
|
|
||||||
the comment row's icon span and dragged it into the text column. Scoped to
|
|
||||||
`span:not(.drawing-icon)`.
|
|
||||||
|
|
||||||
**A comment lost its place when the timeframe changed.** Placed on a 30m bar,
|
|
||||||
then switched to 15m, it slid to the far left. `timeToCoordinate` answers only
|
|
||||||
for times that are data points on the current series, so a 30m bucket start
|
|
||||||
returned `null` on another timeframe — and `null` was being read as "off the
|
|
||||||
left edge". Anchors are now resolved to the bar that *contains* them, which is
|
|
||||||
timeframe-independent: an 09:30 note sits on the 09:30 bar at 15m and on the
|
|
||||||
09:00 bar at 1h. `setBars` also re-renders comments, since a timeframe switch
|
|
||||||
replaces the grid underneath every pinned one.
|
|
||||||
|
|
||||||
Verified across 30m → 15m → 1h → 30m: the anchor stays 08:30 throughout,
|
|
||||||
resolving to the 08:30 bar on 15m and the 08:00 bar on 1h, never edge-parked,
|
|
||||||
and returning to its original x on the way back. Edge-parking still works where
|
|
||||||
it should — a comment scrolled 400 bars out parks right and comes back on
|
|
||||||
return to live.
|
|
||||||
|
|
|
||||||
107
static/app.js
107
static/app.js
|
|
@ -84,13 +84,6 @@ createApp({
|
||||||
const drawColor = ref('#65b7cf');
|
const drawColor = ref('#65b7cf');
|
||||||
const drawWidth = ref(2);
|
const drawWidth = ref(2);
|
||||||
const drawSide = ref('support');
|
const drawSide = ref('support');
|
||||||
const commentText = ref('');
|
|
||||||
const commentFloat = ref(false);
|
|
||||||
// Comments are not levels, so they never arrive over the levels socket and
|
|
||||||
// are fetched and refreshed explicitly.
|
|
||||||
const comments = ref([]);
|
|
||||||
const drawingFilter = ref('');
|
|
||||||
const drawingKind = ref('all');
|
|
||||||
const snap = ref(true);
|
const snap = ref(true);
|
||||||
const selectedLine = ref(null);
|
const selectedLine = ref(null);
|
||||||
const selectedLines = ref([]);
|
const selectedLines = ref([]);
|
||||||
|
|
@ -114,37 +107,6 @@ createApp({
|
||||||
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
|
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
|
||||||
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
|
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
|
||||||
|
|
||||||
// One list for everything drawn. Lines come from the levels socket, which
|
|
||||||
// keeps them live; comments come from the drawings endpoint.
|
|
||||||
const drawings = computed(() => {
|
|
||||||
const lines = manualLines.value.map(line => ({
|
|
||||||
id: line.id,
|
|
||||||
number: line.number,
|
|
||||||
kind: line.slope === 0 ? 'level' : 'trendline',
|
|
||||||
label: line.label || '',
|
|
||||||
line,
|
|
||||||
}));
|
|
||||||
const notes = comments.value.map(comment => ({
|
|
||||||
id: comment.id,
|
|
||||||
number: comment.number,
|
|
||||||
kind: 'comment',
|
|
||||||
label: comment.note || '',
|
|
||||||
comment,
|
|
||||||
}));
|
|
||||||
return [...lines, ...notes].sort((a, b) => a.number - b.number);
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredDrawings = computed(() => {
|
|
||||||
const needle = drawingFilter.value.trim().toLowerCase();
|
|
||||||
return drawings.value.filter(item => {
|
|
||||||
if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false;
|
|
||||||
if (!needle) return true;
|
|
||||||
// Matched against what is on screen — the label or comment text, the
|
|
||||||
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
|
|
||||||
return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
const response = await apiFetch('/api/status');
|
const response = await apiFetch('/api/status');
|
||||||
if (response.ok) status.value = await response.json();
|
if (response.ok) status.value = await response.json();
|
||||||
|
|
@ -248,10 +210,6 @@ createApp({
|
||||||
// One placement per arming, so a tool cannot keep firing on stray drags.
|
// One placement per arming, so a tool cannot keep firing on stray drags.
|
||||||
armedTool.value = null;
|
armedTool.value = null;
|
||||||
chartApi.armTool(null);
|
chartApi.armTool(null);
|
||||||
if (result.tool === 'comment') {
|
|
||||||
await createComment(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.tool === 'level') {
|
if (result.tool === 'level') {
|
||||||
alertPrice.value = result.price;
|
alertPrice.value = result.price;
|
||||||
await addPriceAlert();
|
await addPriceAlert();
|
||||||
|
|
@ -260,64 +218,6 @@ createApp({
|
||||||
await createTrendline(result.start, result.end);
|
await createTrendline(result.start, result.end);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshComments() {
|
|
||||||
const response = await apiFetch('/api/drawings');
|
|
||||||
if (!response.ok) return;
|
|
||||||
const payload = await response.json();
|
|
||||||
comments.value = (payload.drawings || []).filter(item => item.kind === 'comment');
|
|
||||||
syncComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncComments() {
|
|
||||||
if (chartApi) chartApi.setComments(comments.value.filter(item => !item.hidden));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createComment(result) {
|
|
||||||
const text = commentText.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
const response = await apiFetch('/api/comments', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
text,
|
|
||||||
pinned: !commentFloat.value,
|
|
||||||
anchor_t: result.time ?? null,
|
|
||||||
anchor_p: result.price ?? null,
|
|
||||||
x: result.x, y: result.y,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Unable to save comment', response.status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commentText.value = '';
|
|
||||||
await refreshComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patchComment(comment, changes) {
|
|
||||||
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(changes),
|
|
||||||
});
|
|
||||||
if (response.ok) await refreshComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
|
|
||||||
const moveComment = (comment, x, y) => patchComment(comment, { x, y });
|
|
||||||
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
|
|
||||||
|
|
||||||
async function deleteDrawing(item) {
|
|
||||||
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
|
|
||||||
if (item.kind === 'comment') await refreshComments();
|
|
||||||
else levels.value = levels.value.filter(level => level.id !== item.id);
|
|
||||||
syncVisibleLevels();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFilteredDrawings() {
|
|
||||||
const doomed = [...filteredDrawings.value];
|
|
||||||
if (!doomed.length) return;
|
|
||||||
for (const item of doomed) await deleteDrawing(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTrendline(first, second) {
|
async function createTrendline(first, second) {
|
||||||
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
|
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
|
||||||
const side = start.snappedSide || end.snappedSide || drawSide.value;
|
const side = start.snappedSide || end.snappedSide || drawSide.value;
|
||||||
|
|
@ -427,9 +327,6 @@ createApp({
|
||||||
});
|
});
|
||||||
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
|
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
|
||||||
const saved = await response.json();
|
const saved = await response.json();
|
||||||
// A comment is not a level and must not be pushed into the level list,
|
|
||||||
// where it would be drawn as a line.
|
|
||||||
if (line.kind === 'comment') { await refreshComments(); return; }
|
|
||||||
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
}
|
}
|
||||||
|
|
@ -537,8 +434,6 @@ createApp({
|
||||||
chartApi.setLineChangeHandler(updateLineGeometry);
|
chartApi.setLineChangeHandler(updateLineGeometry);
|
||||||
chartApi.setLineEndHandler(endLineHere);
|
chartApi.setLineEndHandler(endLineHere);
|
||||||
chartApi.setSnap(snap.value);
|
chartApi.setSnap(snap.value);
|
||||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
|
|
||||||
refreshComments();
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
connect();
|
connect();
|
||||||
|
|
@ -551,6 +446,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
|
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed };
|
||||||
},
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|
|
||||||
184
static/chart.js
184
static/chart.js
|
|
@ -33,11 +33,6 @@ class ConfluenceChart {
|
||||||
this.toolMoveListener = null;
|
this.toolMoveListener = null;
|
||||||
this.toolUpListener = null;
|
this.toolUpListener = null;
|
||||||
this.pendingView = null;
|
this.pendingView = null;
|
||||||
this.comments = [];
|
|
||||||
this.commentNodes = new Map();
|
|
||||||
this.commentLayer = null;
|
|
||||||
this.onCommentToggle = null;
|
|
||||||
this.onCommentMove = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static TICK = 0.25;
|
static TICK = 0.25;
|
||||||
|
|
@ -166,9 +161,6 @@ class ConfluenceChart {
|
||||||
this.priceTag.className = 'chart-price-tag';
|
this.priceTag.className = 'chart-price-tag';
|
||||||
this.priceTag.hidden = true;
|
this.priceTag.hidden = true;
|
||||||
el.appendChild(this.priceTag);
|
el.appendChild(this.priceTag);
|
||||||
this.commentLayer = document.createElement('div');
|
|
||||||
this.commentLayer.className = 'chart-comments';
|
|
||||||
el.appendChild(this.commentLayer);
|
|
||||||
this.toolDownListener = event => this.startToolGesture(event);
|
this.toolDownListener = event => this.startToolGesture(event);
|
||||||
this.toolMoveListener = event => this.moveToolGesture(event);
|
this.toolMoveListener = event => this.moveToolGesture(event);
|
||||||
this.toolUpListener = event => this.finishToolGesture(event);
|
this.toolUpListener = event => this.finishToolGesture(event);
|
||||||
|
|
@ -200,10 +192,7 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
this.updateLineTooltip(param);
|
this.updateLineTooltip(param);
|
||||||
});
|
});
|
||||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
|
||||||
this.renderAnchorHandles();
|
|
||||||
this.renderComments();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setBars(bars) {
|
setBars(bars) {
|
||||||
|
|
@ -229,8 +218,6 @@ class ConfluenceChart {
|
||||||
this.chart.timeScale().setVisibleRange(this.pendingView);
|
this.chart.timeScale().setVisibleRange(this.pendingView);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => this.renderAnchorHandles());
|
requestAnimationFrame(() => this.renderAnchorHandles());
|
||||||
// The bar grid just changed underneath every pinned comment.
|
|
||||||
this.renderComments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBar(bar) {
|
updateBar(bar) {
|
||||||
|
|
@ -248,130 +235,6 @@ class ConfluenceChart {
|
||||||
this.renderAnchorHandles();
|
this.renderAnchorHandles();
|
||||||
}
|
}
|
||||||
|
|
||||||
setComments(comments) {
|
|
||||||
this.comments = comments;
|
|
||||||
this.renderComments();
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommentHandlers({ onToggle, onMove }) {
|
|
||||||
this.onCommentToggle = onToggle;
|
|
||||||
this.onCommentMove = onMove;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
|
|
||||||
// and a floating one must ignore the time scale entirely.
|
|
||||||
renderComments() {
|
|
||||||
if (!this.commentLayer) return;
|
|
||||||
const width = this.chartEl.clientWidth;
|
|
||||||
const height = this.chartEl.clientHeight;
|
|
||||||
const seen = new Set();
|
|
||||||
for (const comment of this.comments || []) {
|
|
||||||
seen.add(comment.id);
|
|
||||||
let node = this.commentNodes.get(comment.id);
|
|
||||||
if (!node) {
|
|
||||||
node = this.buildCommentNode(comment);
|
|
||||||
this.commentNodes.set(comment.id, node);
|
|
||||||
this.commentLayer.appendChild(node.root);
|
|
||||||
}
|
|
||||||
node.comment = comment;
|
|
||||||
node.root.classList.toggle('collapsed', !!comment.collapsed);
|
|
||||||
node.root.classList.toggle('floating', !comment.pinned);
|
|
||||||
node.label.textContent = comment.collapsed ? String(comment.number) : comment.note;
|
|
||||||
node.root.style.borderColor = comment.color;
|
|
||||||
node.root.title = comment.note;
|
|
||||||
|
|
||||||
if (!comment.pinned) {
|
|
||||||
// A fraction of the pane, so it holds its place through any zoom.
|
|
||||||
node.root.style.left = `${Math.round(comment.x * width)}px`;
|
|
||||||
node.root.style.top = `${Math.round(comment.y * height)}px`;
|
|
||||||
node.root.classList.remove('off-left', 'off-right');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const placed = this.anchorCoordinate(comment.anchor_t);
|
|
||||||
const x = placed.x;
|
|
||||||
const y = this.candles.priceToCoordinate(comment.anchor_p);
|
|
||||||
// Scrolled past: park it on the edge it went off, pointing that way,
|
|
||||||
// rather than letting it vanish and be hard to find again.
|
|
||||||
const offLeft = placed.before || (x !== null && x < 0);
|
|
||||||
const offRight = placed.after || (x !== null && x > width);
|
|
||||||
node.root.classList.toggle('off-left', offLeft && !offRight);
|
|
||||||
node.root.classList.toggle('off-right', offRight);
|
|
||||||
const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x;
|
|
||||||
node.root.style.left = `${Math.round(clampedX)}px`;
|
|
||||||
node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`;
|
|
||||||
}
|
|
||||||
for (const [id, node] of this.commentNodes) {
|
|
||||||
if (!seen.has(id)) {
|
|
||||||
node.root.remove();
|
|
||||||
this.commentNodes.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where an anchor time sits on the current timeframe.
|
|
||||||
*
|
|
||||||
* timeToCoordinate answers only for times that are data points on the series,
|
|
||||||
* so a comment placed on a 30m bar returned null the moment the chart
|
|
||||||
* switched to 15m or 1h — and null was being read as "off the left edge",
|
|
||||||
* which slid every such comment to the far left. The anchor is resolved to
|
|
||||||
* the bar that *contains* it instead, which is timeframe-independent: a 09:30
|
|
||||||
* note lands on the 09:30 bar at 15m and on the 09:00 bar at 1h.
|
|
||||||
*/
|
|
||||||
anchorCoordinate(anchorT) {
|
|
||||||
if (!this.bars.length) return { x: null, before: false, after: false };
|
|
||||||
if (anchorT < this.bars[0].t) return { x: null, before: true, after: false };
|
|
||||||
const lastBar = this.bars[this.bars.length - 1];
|
|
||||||
if (anchorT > lastBar.t) {
|
|
||||||
// Past the newest bar is not off the chart — the right edge carries
|
|
||||||
// whitespace, so ask the scale directly and only park it if it answers.
|
|
||||||
const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t);
|
|
||||||
return { x: beyond, before: false, after: beyond === null };
|
|
||||||
}
|
|
||||||
let lo = 0;
|
|
||||||
let hi = this.bars.length - 1;
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi + 1) >> 1;
|
|
||||||
if (this.bars[mid].t <= anchorT) lo = mid;
|
|
||||||
else hi = mid - 1;
|
|
||||||
}
|
|
||||||
return { x: this.chart.timeScale().timeToCoordinate(this.bars[lo].t), before: false, after: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCommentNode(comment) {
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'chart-comment';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'chart-comment-text';
|
|
||||||
root.appendChild(label);
|
|
||||||
const node = { root, label, comment };
|
|
||||||
root.addEventListener('click', event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (this.onCommentToggle) this.onCommentToggle(node.comment);
|
|
||||||
});
|
|
||||||
// Floating comments are dragged; pinned ones belong to their bar.
|
|
||||||
root.addEventListener('pointerdown', event => {
|
|
||||||
if (node.comment.pinned) return;
|
|
||||||
event.stopPropagation();
|
|
||||||
const bounds = this.chartEl.getBoundingClientRect();
|
|
||||||
const move = moveEvent => {
|
|
||||||
root.style.left = `${Math.round(moveEvent.clientX - bounds.left - 8)}px`;
|
|
||||||
root.style.top = `${Math.round(moveEvent.clientY - bounds.top - 8)}px`;
|
|
||||||
};
|
|
||||||
const up = upEvent => {
|
|
||||||
window.removeEventListener('pointermove', move);
|
|
||||||
window.removeEventListener('pointerup', up);
|
|
||||||
const x = (upEvent.clientX - bounds.left - 8) / bounds.width;
|
|
||||||
const y = (upEvent.clientY - bounds.top - 8) / bounds.height;
|
|
||||||
const clamp = value => Math.min(Math.max(value, 0), 1);
|
|
||||||
if (this.onCommentMove) this.onCommentMove(node.comment, clamp(x), clamp(y));
|
|
||||||
};
|
|
||||||
window.addEventListener('pointermove', move);
|
|
||||||
window.addEventListener('pointerup', up);
|
|
||||||
});
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A typed price alert is a manual line with zero slope. Treating it as flat
|
// A typed price alert is a manual line with zero slope. Treating it as flat
|
||||||
// here is what makes it render as a level rather than a stubby segment.
|
// here is what makes it render as a level rather than a stubby segment.
|
||||||
static isFlat(level) {
|
static isFlat(level) {
|
||||||
|
|
@ -578,21 +441,6 @@ class ConfluenceChart {
|
||||||
this.clearGesture();
|
this.clearGesture();
|
||||||
this.chartEl.releasePointerCapture?.(event.pointerId);
|
this.chartEl.releasePointerCapture?.(event.pointerId);
|
||||||
|
|
||||||
if (tool === 'comment') {
|
|
||||||
// Both placements come from one gesture: the time and price under the
|
|
||||||
// cursor for a pinned comment, and the same spot as a fraction of the
|
|
||||||
// pane for a floating one, so the tool decides which without a second
|
|
||||||
// interaction.
|
|
||||||
this.onToolComplete?.({
|
|
||||||
tool,
|
|
||||||
time: end.t,
|
|
||||||
price: ConfluenceChart.snapToTick(end.p),
|
|
||||||
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
|
|
||||||
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tool === 'level') {
|
if (tool === 'level') {
|
||||||
// A click with no drag is a valid placement; the drag is only there to
|
// A click with no drag is a valid placement; the drag is only there to
|
||||||
// let you fine-tune the price before committing.
|
// let you fine-tune the price before committing.
|
||||||
|
|
@ -605,15 +453,8 @@ class ConfluenceChart {
|
||||||
// it. Rejecting short gestures outright left the tool armed and silent, so
|
// it. Rejecting short gestures outright left the tool armed and silent, so
|
||||||
// the next click began a whole new line — which read as the line suddenly
|
// the next click began a whole new line — which read as the line suddenly
|
||||||
// continuing at a different slope.
|
// continuing at a different slope.
|
||||||
// A placed anchor wins over this click's own drag. Recomputing `dragged`
|
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
||||||
// here meant a few pixels of movement while pressing the second click was
|
const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end));
|
||||||
// read as a fresh press-drag-release, which threw the anchor away and
|
|
||||||
// started the line at the second click instead — the preview had been
|
|
||||||
// rubber-banding from the real anchor all along, so the finished line
|
|
||||||
// jumped away from where it had just been drawn.
|
|
||||||
const dragged = !this.pendingAnchor
|
|
||||||
&& Math.hypot(end.x - start.x, end.y - start.y) >= 5;
|
|
||||||
const a = this.pendingAnchor ?? this.snapPoint(dragged ? start : end);
|
|
||||||
const b = this.snapPoint(end);
|
const b = this.snapPoint(end);
|
||||||
if (!dragged && !this.pendingAnchor) {
|
if (!dragged && !this.pendingAnchor) {
|
||||||
this.pendingAnchor = b;
|
this.pendingAnchor = b;
|
||||||
|
|
@ -626,23 +467,11 @@ class ConfluenceChart {
|
||||||
this.onToolComplete?.({ tool, start: a, end: b });
|
this.onToolComplete?.({ tool, start: a, end: b });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Trendline anchors snap to bar extremes; levels snap to the tick grid. */
|
||||||
* Trendline anchors snap to bar extremes; levels snap to the tick grid.
|
|
||||||
*
|
|
||||||
* With snapping on, an anchor always lands on the nearer extreme of the
|
|
||||||
* nearest bar — there is no proximity gate. An 8px one used to let the
|
|
||||||
* cursor fall between the high and the low and snap to neither, so the
|
|
||||||
* preview showed a raw mid-bar price and the side quietly fell back to the
|
|
||||||
* dropdown. Which extreme you land on *is* the side: a high is resistance, a
|
|
||||||
* low is support, so the choice is made by where you point rather than by a
|
|
||||||
* control you have to remember to set. An already-snapped point carries no
|
|
||||||
* cursor y, so it is returned untouched rather than re-snapped against NaN.
|
|
||||||
*/
|
|
||||||
snapPoint(point) {
|
snapPoint(point) {
|
||||||
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
|
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
|
||||||
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
|
const base = { t: fallbackT, p: point.p, snappedSide: null };
|
||||||
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
||||||
if (point.y == null) return base;
|
|
||||||
const nearest = this.bars.reduce(
|
const nearest = this.bars.reduce(
|
||||||
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
||||||
);
|
);
|
||||||
|
|
@ -653,7 +482,8 @@ class ConfluenceChart {
|
||||||
const snapped = candidates
|
const snapped = candidates
|
||||||
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
|
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
|
||||||
.sort((a, b) => a.distance - b.distance)[0];
|
.sort((a, b) => a.distance - b.distance)[0];
|
||||||
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGesture() {
|
renderGesture() {
|
||||||
|
|
|
||||||
|
|
@ -35,30 +35,13 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside>
|
<aside>
|
||||||
<details class="sidebar-section">
|
<h2>Tools</h2>
|
||||||
<summary>Layers</summary>
|
|
||||||
<div class="layer-group">
|
|
||||||
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
|
||||||
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
|
|
||||||
</div>
|
|
||||||
<div class="layer-group">
|
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
|
||||||
</div>
|
|
||||||
<div class="layer-group layer-inline">
|
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
|
||||||
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label>
|
|
||||||
</div>
|
|
||||||
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
|
||||||
</details>
|
|
||||||
<details class="sidebar-section tools-section" open>
|
|
||||||
<summary>Tools</summary>
|
|
||||||
<div class="tool" :class="{armed: armedTool === 'trendline'}">
|
<div class="tool" :class="{armed: armedTool === 'trendline'}">
|
||||||
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
|
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
|
||||||
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
|
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
|
||||||
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
|
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="tool-body" v-show="armedTool === 'trendline'">
|
<div class="tool-body">
|
||||||
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
|
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Colour<input type="color" v-model="drawColor" aria-label="Trendline colour"></label>
|
<label>Colour<input type="color" v-model="drawColor" aria-label="Trendline colour"></label>
|
||||||
|
|
@ -75,7 +58,7 @@
|
||||||
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
|
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
|
||||||
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
|
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="tool-body" v-show="armedTool === 'level'">
|
<div class="tool-body">
|
||||||
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
|
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
||||||
|
|
@ -88,76 +71,42 @@
|
||||||
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool" :class="{armed: armedTool === 'comment'}">
|
<details class="sidebar-section" open>
|
||||||
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
|
<summary>Layers</summary>
|
||||||
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
|
<div class="layer-group">
|
||||||
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
|
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
||||||
</button>
|
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
|
||||||
<div class="tool-body" v-show="armedTool === 'comment'">
|
|
||||||
<label>Text<textarea v-model.trim="commentText" rows="2" placeholder="What happened here?" aria-label="Comment text"></textarea></label>
|
|
||||||
<label class="check"><input type="checkbox" v-model="commentFloat">Float (stay on screen)</label>
|
|
||||||
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="layer-group">
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
||||||
|
</div>
|
||||||
|
<div class="layer-group layer-inline">
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
||||||
|
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label>
|
||||||
|
</div>
|
||||||
|
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
||||||
</details>
|
</details>
|
||||||
<details class="sidebar-section" open>
|
<details class="sidebar-section" open>
|
||||||
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
|
<summary>Lines & levels ({{ manualLines.length }})</summary>
|
||||||
<div class="drawing-filters">
|
<div class="trendline-actions" v-if="manualLines.length">
|
||||||
<select v-model="drawingKind" aria-label="Filter drawings by type">
|
|
||||||
<option value="all">All types</option>
|
|
||||||
<option value="trendline">Trendlines</option>
|
|
||||||
<option value="level">Levels</option>
|
|
||||||
<option value="comment">Comments</option>
|
|
||||||
</select>
|
|
||||||
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
|
|
||||||
</div>
|
|
||||||
<div class="trendline-actions" v-if="drawings.length">
|
|
||||||
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
|
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
|
||||||
<button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
|
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
|
||||||
:title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
|
|
||||||
Delete shown ({{ filteredDrawings.length }})
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
|
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div>
|
||||||
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
|
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
|
||||||
|
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
|
||||||
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
|
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
|
||||||
:class="{selected: selectedLines.includes(item.id)}"
|
<span v-if="line.slope === 0">#{{ line.number }} · level {{ line.anchor_p.toFixed(2) }}</span>
|
||||||
@click="item.kind !== 'comment' && toggleLineSelection(item.id)">
|
<span v-else>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
|
||||||
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
|
<label class="armed-toggle" :class="{off: !line.armed}" @click.stop :title="line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
|
||||||
:checked="selectedLines.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
<input type="checkbox" :checked="line.armed" @change="setArmed(line, $event.target.checked)">
|
||||||
@click.stop @change="toggleLineSelection(item.id)">
|
{{ line.armed ? 'armed' : 'tripped' }}
|
||||||
<span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span>
|
|
||||||
|
|
||||||
<input v-if="item.kind !== 'comment'" :value="item.line.label" aria-label="Drawing name"
|
|
||||||
@click.stop @change="renameLine(item.line, $event.target.value)">
|
|
||||||
<input v-else :value="item.comment.note" aria-label="Comment text"
|
|
||||||
@click.stop @change="renameLine(item.comment, $event.target.value)">
|
|
||||||
|
|
||||||
<span v-if="item.kind === 'comment'">#{{ item.number }} · comment · {{ item.comment.pinned ? 'pinned' : 'floating' }}</span>
|
|
||||||
<span v-else-if="item.kind === 'level'">#{{ item.number }} · level {{ item.line.anchor_p.toFixed(2) }}</span>
|
|
||||||
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side }}</span>
|
|
||||||
|
|
||||||
<label v-if="item.kind === 'comment'" class="armed-toggle" @click.stop
|
|
||||||
:title="item.comment.pinned ? 'Pinned to its bar — scrolls with the chart' : 'Floating — always on screen'">
|
|
||||||
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
|
|
||||||
{{ item.comment.pinned ? 'pinned' : 'float' }}
|
|
||||||
</label>
|
</label>
|
||||||
<label v-else class="armed-toggle" :class="{off: !item.line.armed}" @click.stop
|
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
|
||||||
:title="item.line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
|
<div class="line-style-controls" @click.stop>
|
||||||
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
|
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
|
||||||
{{ item.line.armed ? 'armed' : 'tripped' }}
|
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
|
||||||
</label>
|
|
||||||
|
|
||||||
<button @click.stop="deleteDrawing(item)" aria-label="Delete drawing">Delete</button>
|
|
||||||
|
|
||||||
<div class="line-style-controls" @click.stop v-if="item.kind !== 'comment'">
|
|
||||||
<input type="color" :value="item.line.color || '#65b7cf'" aria-label="Drawing color" @change="updateLineStyle(item.line, { color: $event.target.value })">
|
|
||||||
<select :value="item.line.line_width || 2" aria-label="Drawing width" @change="updateLineStyle(item.line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
|
|
||||||
</div>
|
|
||||||
<div class="line-style-controls" @click.stop v-else>
|
|
||||||
<input type="color" :value="item.comment.color || '#c8992f'" aria-label="Comment colour" @change="updateLineStyle(item.comment, { color: $event.target.value })">
|
|
||||||
<button class="collapse-toggle" @click.stop="toggleComment(item.comment)">{{ item.comment.collapsed ? 'expand' : 'collapse' }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
|
||||||
.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); }.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); }.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; }
|
.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; }
|
||||||
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; }
|
||||||
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
||||||
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
|
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
|
||||||
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
|
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
|
||||||
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
||||||
|
|
||||||
|
|
@ -52,27 +52,3 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
||||||
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||||
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
|
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
|
||||||
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
||||||
/* --- chart comments ---------------------------------------------------- */
|
|
||||||
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
|
||||||
.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted);
|
|
||||||
border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg);
|
|
||||||
font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap;
|
|
||||||
box-shadow:0 1px 3px rgba(0,0,0,.16); }
|
|
||||||
.chart-comment.collapsed { max-width:none; width:18px; height:18px; padding:0; display:flex;
|
|
||||||
align-items:center; justify-content:center; border-radius:50%; border-left-width:1px;
|
|
||||||
font-size:9px; white-space:nowrap; }
|
|
||||||
.chart-comment.floating { border-style:dashed; cursor:grab; }
|
|
||||||
/* Parked on the edge it scrolled off, pointing the way back to it. */
|
|
||||||
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
|
|
||||||
.chart-comment.off-left::before { content:'◀ '; }
|
|
||||||
.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; }
|
|
||||||
.chart-comment.off-left, .chart-comment.off-right { opacity:.72; }
|
|
||||||
/* --- drawings filter --------------------------------------------------- */
|
|
||||||
.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; }
|
|
||||||
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
|
|
||||||
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
|
|
||||||
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }
|
|
||||||
.collapse-toggle { font:inherit; font-size:9px; padding:2px 6px; border:1px solid var(--line);
|
|
||||||
border-radius:4px; background:transparent; color:var(--muted); cursor:pointer; }
|
|
||||||
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
|
|
||||||
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
|
|
||||||
|
|
|
||||||
|
|
@ -40,52 +40,3 @@ def test_manual_line_raises_existing_ma_cluster_score():
|
||||||
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
|
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
|
||||||
assert before.score == 12
|
assert before.score == 12
|
||||||
assert after.score == 16
|
assert after.score == 16
|
||||||
|
|
||||||
|
|
||||||
def test_a_comment_is_never_a_level(tmp_path):
|
|
||||||
# Comments live with the lines so they share numbering, filtering and
|
|
||||||
# deletion — but a comment reaching levels() would join a confluence
|
|
||||||
# cluster and fire a push notification about a piece of text.
|
|
||||||
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
|
||||||
from app.analysis.levels import Side
|
|
||||||
from app.bars.models import Timeframe
|
|
||||||
|
|
||||||
store = ManualLineStore(tmp_path / "lines.json")
|
|
||||||
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
|
||||||
anchor_t=1000, last_t=2000, created_at=1000)
|
|
||||||
store.add(ManualLine(id="ml_level", slope=0.0, **common))
|
|
||||||
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
|
|
||||||
note="watch this", **common))
|
|
||||||
|
|
||||||
assert [level.id for level in store.levels()] == ["ml_level"]
|
|
||||||
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
|
|
||||||
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
|
||||||
from app.analysis.levels import Side
|
|
||||||
from app.bars.models import Timeframe
|
|
||||||
|
|
||||||
store = ManualLineStore(tmp_path / "lines.json")
|
|
||||||
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
|
||||||
anchor_t=1000, last_t=2000, created_at=1000)
|
|
||||||
flat = store.add(ManualLine(id="ml_flat", slope=0.0, **common))
|
|
||||||
sloped = store.add(ManualLine(id="ml_sloped", slope=0.5, **common))
|
|
||||||
|
|
||||||
assert flat.drawing_kind == "level"
|
|
||||||
assert sloped.drawing_kind == "trendline"
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_drawing_gets_a_number_including_comments(tmp_path):
|
|
||||||
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
|
||||||
from app.analysis.levels import Side
|
|
||||||
from app.bars.models import Timeframe
|
|
||||||
|
|
||||||
store = ManualLineStore(tmp_path / "lines.json")
|
|
||||||
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
|
||||||
anchor_t=1000, last_t=2000, created_at=1000)
|
|
||||||
first = store.add(ManualLine(id="ml_a", slope=0.0, **common))
|
|
||||||
note = store.add(ManualLine(id="ml_b", slope=0.0, kind="comment", **common))
|
|
||||||
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
|
|
||||||
|
|
||||||
assert [first.number, note.number, third.number] == [1, 2, 3]
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue