Compare commits

..

No commits in common. "9b6cff853b9c48058513d2abf0f90cde48197662" and "a0b8ac1cd2b8f9f6fd978bc335f950bd7c02a4de" have entirely different histories.

8 changed files with 45 additions and 605 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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.

View file

@ -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');

View file

@ -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() {

View file

@ -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 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>
<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 &amp; 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>

View file

@ -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); }

View file

@ -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]