From 3b3c06a1f3f0ed503cfbe11db95171df88373d9a Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 05:46:09 -0500 Subject: [PATCH] - Drag a selected trendline body to reposition the entire line. - Duplicate and delete from the line context menu. - Copies shift ten bars right. - Default names are up and down; copies become up 2, down 2, etc. - Exact local data receipt time including seconds. - Deployment timestamp removed. - Test cleanup no longer deletes drawings created from your browser. - JWT password session flow. --- .env.example | 1 + Dockerfile.dev | 4 +- README.md | 20 +++-- app/analysis/manual_lines.py | 2 +- app/api/deps.py | 92 +++++++++++++++++--- app/api/meta.py | 48 +++++++++-- app/api/routes.py | 4 + app/api/ws.py | 20 ++++- app/config.py | 1 + requirements.txt | 1 + static/app.js | 157 ++++++++++++++++++++++++----------- static/chart.js | 140 +++++++++++++++++++++++++++++-- static/index.html | 3 +- static/style.css | 6 +- tests/e2e/chart.test.mjs | 78 ++++++++++++++--- tests/e2e/harness.test.mjs | 50 +++++++++++ tests/e2e/helpers.mjs | 27 ++++-- tests/e2e/trendline.test.mjs | 129 +++++++++++++++++++++++++++- tests/test_auth.py | 106 ++++++++++++++++++++++- tests/test_manual_lines.py | 10 +++ 20 files changed, 784 insertions(+), 115 deletions(-) create mode 100644 tests/e2e/harness.test.mjs diff --git a/.env.example b/.env.example index c0d4cbe..1ede2e8 100644 --- a/.env.example +++ b/.env.example @@ -36,4 +36,5 @@ NTFY_SERVER=https://ntfy.sh # Blank = no auth (fine locally). In production this is set in Coolify, not # here — see README. Sent as the X-Chart-Token header, or ?token= for /ws. CHART_AUTH_TOKEN= +CHART_PASSWORD= REPLAY_FILE= diff --git a/Dockerfile.dev b/Dockerfile.dev index 93a6e20..b495f92 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,8 +2,8 @@ FROM python:3.12-slim WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY requirements.txt requirements-dev.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt COPY . . diff --git a/README.md b/README.md index 6558344..72a29e4 100644 --- a/README.md +++ b/README.md @@ -232,22 +232,26 @@ curl -s -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status # want: "source":"schwab","delay_minutes":0 ``` -## Access token +## Browser password and API token `CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave it blank and the app is wide open, which is what you want locally — nothing -prompts. Set it and every request needs the token, as the `X-Chart-Token` -header or a `?token=` query parameter (WebSocket handshakes can't carry -headers, hence the second form). +prompts. Scripts can supply it as the `X-Chart-Token` header or a `?token=` +query parameter. -In production the token lives in **Coolify's environment variables**, not in +Set `CHART_PASSWORD` to a human-friendly passphrase for browser access. The +browser posts it once to `/api/login`; the server returns a signed, HttpOnly, +30-day HS256 JWT cookie used by both API requests and the WebSocket. The opaque +API token is never returned to or stored by the browser. If `CHART_PASSWORD` is +temporarily absent, the login accepts `CHART_AUTH_TOKEN` as a migration fallback. +Existing browsers that stored a token under the old flow exchange it once for a +session and remove it from `localStorage`. `POST /api/logout` clears the session. + +In production both secrets live in **Coolify's environment variables**, not in this repo and not in `.env` — that file is gitignored and never exists in the built container. Coolify re-injects its env vars into every container it builds, so the token survives redeploys and reboots. -The browser asks for it once on the first 401 and keeps it in `localStorage`. -To clear it: `localStorage.removeItem('chart-token')`. - `/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy` polls the latter from whatever machine you pushed from, and neither reveals anything about the market data or the configuration. diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index acce005..56ea712 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -57,7 +57,7 @@ class ManualLine: def default_label(self) -> str: if self.horizontal: return f"@ {self.anchor_p:.2f}" - return f"{self.tf.value} {self.side.value}" + return "up" if self.side is Side.SUPPORT else "down" def to_level(self) -> Level: return Level( diff --git a/app/api/deps.py b/app/api/deps.py index 6d4804e..4f03b42 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1,11 +1,28 @@ +import hashlib import secrets +import time +import jwt from fastapi import HTTPException, Request, status +from jwt import InvalidTokenError + +SESSION_COOKIE = "chart-session" +SESSION_MAX_AGE = 60 * 60 * 24 * 30 + + +def configured_settings(app): + runtime = getattr(app.state, "runtime", None) + return runtime.settings if runtime else None def configured_token(app) -> str: - runtime = getattr(app.state, "runtime", None) - return runtime.settings.chart_auth_token if runtime else "" + settings = configured_settings(app) + return settings.chart_auth_token if settings else "" + + +def configured_password(app) -> str: + settings = configured_settings(app) + return settings.chart_password if settings else "" def token_matches(app, presented: str) -> bool: @@ -16,15 +33,68 @@ def token_matches(app, presented: str) -> bool: """ want = configured_token(app) if not want: - return True - return secrets.compare_digest(presented or "", want) + return not configured_password(app) + return secrets.compare_digest((presented or "").encode(), want.encode()) -def require_token(request: Request) -> None: - presented = request.headers.get("x-chart-token") or request.query_params.get("token", "") - if not token_matches(request.app, presented): - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - "Missing or invalid chart token", - headers={"WWW-Authenticate": "X-Chart-Token"}, +def password_matches(app, presented: str) -> bool: + # Falling back to the token avoids locking out a deployment while + # CHART_PASSWORD is being added. Once set, only the friendly password logs + # a browser in; the opaque token remains valid for direct API clients. + want = configured_password(app) or configured_token(app) + return bool(want) and secrets.compare_digest( + (presented or "").encode(), want.encode() + ) + + +def session_secret(app) -> bytes: + configured = configured_token(app) or configured_password(app) + return hashlib.sha256(configured.encode()).digest() if configured else b"" + + +def create_session(app, now: int | None = None) -> str: + secret = session_secret(app) + if not secret: + return "" + issued = now if now is not None else int(time.time()) + return jwt.encode( + {"sub": "shared", "iat": issued, "exp": issued + SESSION_MAX_AGE}, + secret, + algorithm="HS256", + ) + + +def session_principal(app, presented: str) -> str | None: + secret = session_secret(app) + if not secret or not presented: + return None + try: + payload = jwt.decode( + presented, + secret, + algorithms=["HS256"], + options={"require": ["sub", "iat", "exp"]}, ) + except InvalidTokenError: + return None + principal = payload.get("sub") + return principal if isinstance(principal, str) and principal else None + + +def session_matches(app, presented: str) -> bool: + return session_principal(app, presented) is not None + + +def require_token(request: Request) -> str: + presented = request.headers.get("x-chart-token") or request.query_params.get("token", "") + session = request.cookies.get(SESSION_COOKIE, "") + if token_matches(request.app, presented): + return "api-token" if configured_token(request.app) else "anonymous" + principal = session_principal(request.app, session) + if principal is not None: + return principal + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "Authentication required", + headers={"WWW-Authenticate": "Session, X-Chart-Token"}, + ) diff --git a/app/api/meta.py b/app/api/meta.py index 9093b6c..424c9b3 100644 --- a/app/api/meta.py +++ b/app/api/meta.py @@ -1,14 +1,23 @@ """Endpoints that stay reachable without a token. -`bin/wait-deploy` polls /api/version from whatever machine you pushed from, so -requiring the token here would mean carrying it around just to answer "is my -commit live yet". Neither endpoint exposes anything about the market data or -the configuration. +`bin/wait-deploy` polls /api/version from whatever machine you pushed from, and +the browser needs /api/login before it has a session, so these routes stay +outside the protected API router. """ import os from datetime import datetime, timezone -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, Request, Response, status +from pydantic import BaseModel + +from app.api.deps import ( + SESSION_COOKIE, + SESSION_MAX_AGE, + configured_token, + create_session, + password_matches, + token_matches, +) router = APIRouter(prefix="/api") @@ -17,6 +26,10 @@ SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev") STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat() +class LoginRequest(BaseModel): + password: str + + @router.get("/health") def health(): return {"status": "ok", "service": "chart"} @@ -25,3 +38,28 @@ def health(): @router.get("/version") def version(): return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT} + + +@router.post("/login", status_code=status.HTTP_204_NO_CONTENT) +def login(credentials: LoginRequest, request: Request, response: Response): + presented_token = request.headers.get("x-chart-token", "") + token_login = bool(configured_token(request.app)) and token_matches( + request.app, presented_token + ) + if not token_login and not password_matches(request.app, credentials.password): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect password") + forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0] + response.set_cookie( + SESSION_COOKIE, + create_session(request.app), + max_age=SESSION_MAX_AGE, + httponly=True, + secure=request.url.scheme == "https" or forwarded_proto == "https", + samesite="strict", + path="/", + ) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout(response: Response): + response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict") diff --git a/app/api/routes.py b/app/api/routes.py index 4ebbd0c..199f753 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -28,6 +28,8 @@ class LineCreate(BaseModel): hidden: bool = False color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$") line_width: int = Field(2, ge=1, le=4) + cutoff_t: int | None = None + armed: bool = True class PriceAlertCreate(BaseModel): @@ -125,6 +127,8 @@ def create_line(request: Request, payload: LineCreate): hidden=payload.hidden, color=payload.color, line_width=payload.line_width, + cutoff_t=payload.cutoff_t, + armed=payload.armed, ) runtime = request.app.state.runtime line = runtime.manual_lines.add(line) diff --git a/app/api/ws.py b/app/api/ws.py index 0789293..da35987 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -1,14 +1,21 @@ import asyncio +from urllib.parse import urlsplit from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from app.api.deps import token_matches +from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.bars.models import Timeframe from app.analysis.confluence import cluster_levels router = APIRouter() +def same_origin(websocket: WebSocket) -> bool: + origin = websocket.headers.get("origin", "") + host = websocket.headers.get("host", "") + return bool(origin and host) and urlsplit(origin).netloc == host + + def level_enabled(level, enabled: dict) -> bool: kind = level.kind.value if kind == "ma": @@ -54,9 +61,14 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: @router.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): - # Browsers cannot set headers on a WebSocket handshake, so the token comes - # in as a query parameter here. 1008 = policy violation. - if not token_matches(websocket.app, websocket.query_params.get("token", "")): + # Browsers automatically include the HttpOnly session cookie in the + # handshake. Query-token support remains for non-browser clients and for + # tabs migrating from the previous localStorage-based login. + token_ok = token_matches(websocket.app, websocket.query_params.get("token", "")) + session_ok = same_origin(websocket) and session_matches( + websocket.app, websocket.cookies.get(SESSION_COOKIE, "") + ) + if not token_ok and not session_ok: await websocket.close(code=1008, reason="Missing or invalid chart token") return await websocket.accept() diff --git a/app/config.py b/app/config.py index f7f5412..c418604 100644 --- a/app/config.py +++ b/app/config.py @@ -57,6 +57,7 @@ class Settings(BaseSettings): ntfy_topic: str = "" ntfy_server: str = "https://ntfy.sh" chart_auth_token: str = "" + chart_password: str = "" replay_file: Path | None = None @property diff --git a/requirements.txt b/requirements.txt index 5d1aca2..1953193 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,6 @@ fastapi uvicorn[standard] httpx pydantic-settings +PyJWT # Live futures stream; imported only when LIVE_SOURCE=schwab. schwab-py diff --git a/static/app.js b/static/app.js index 96fc487..7a1e4b1 100644 --- a/static/app.js +++ b/static/app.js @@ -1,24 +1,57 @@ const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue; -// Shared access token. Blank when the server runs without CHART_AUTH_TOKEN, -// which is the normal local-development case — nothing prompts. +// Keep an existing token long enough to migrate an already-open browser to the +// password session. Successful login removes it; new browsers never store the +// API token at all. const TOKEN_KEY = 'chart-token'; let authToken = localStorage.getItem(TOKEN_KEY) || ''; // Reconnects and the status poll both hit 401s, so without this a visitor // who cancels gets asked again every couple of seconds. let promptDeclined = false; +let loginPromise = null; -function promptForToken() { - if (promptDeclined) return false; - const entered = window.prompt('Access token for this chart', ''); - if (entered === null) { - promptDeclined = true; - return false; +async function migrateLegacyToken() { + if (!authToken) return; + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Chart-Token': authToken }, + body: JSON.stringify({ password: '' }), + }); + if (!response.ok) return; + authToken = ''; + localStorage.removeItem(TOKEN_KEY); +} + +async function runPasswordLogin() { + while (!promptDeclined) { + const entered = window.prompt('Password for this chart', ''); + if (entered === null) { + promptDeclined = true; + return false; + } + const response = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: entered }), + }); + if (!response.ok) { + window.alert('Incorrect password'); + continue; + } + authToken = ''; + localStorage.removeItem(TOKEN_KEY); + return true; } - authToken = entered.trim(); - localStorage.setItem(TOKEN_KEY, authToken); - return true; + return false; +} + +async function loginWithPassword() { + if (promptDeclined) return false; + if (!loginPromise) { + loginPromise = runPasswordLogin().finally(() => { loginPromise = null; }); + } + return loginPromise; } async function apiFetch(url, options = {}) { @@ -30,7 +63,7 @@ async function apiFetch(url, options = {}) { }); const response = await send(); // A 401 means nothing was written, so retrying the same request is safe. - if (response.status === 401 && promptForToken()) return send(); + if (response.status === 401 && await loginWithPassword()) return send(); return response; } @@ -71,9 +104,7 @@ function loadPrefs() { createApp({ setup() { const status = ref({ stream: 'disconnected', bars_held: {} }); - const deployment = ref(null); const dataReceivedAt = ref(null); - const dataReceiptVerb = ref('LOADED'); const price = ref(null); const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); @@ -113,28 +144,11 @@ createApp({ const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t)); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; }); - const deployedAt = computed(() => { - if (!deployment.value?.started_at) return '—'; + const dataUpdatedAt = computed(() => { + if (dataReceivedAt.value == null) return '—'; return new Intl.DateTimeFormat(undefined, { - month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', - }).format(new Date(deployment.value.started_at)); - }); - const deployMeta = computed(() => { - if (!deployment.value) return '—'; - return `${deployment.value.commit.slice(0, 8)} · ${deployedAt.value}`; - }); - const deploymentTitle = computed(() => { - if (!deployment.value?.started_at) return ''; - const timestamp = new Date(deployment.value.started_at).toLocaleString(); - return `Running since ${timestamp} · ${deployment.value.commit}`; - }); - const dataAge = computed(() => { - if (dataReceivedAt.value == null) return 'WAITING'; - const seconds = Math.max(0, Math.floor((now.value - dataReceivedAt.value) / 1000)); - if (seconds < 5) return 'JUST NOW'; - if (seconds < 60) return `${seconds}s AGO`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m AGO`; - return `${Math.floor(seconds / 3600)}h AGO`; + hour: 'numeric', minute: '2-digit', second: '2-digit', + }).format(new Date(dataReceivedAt.value)); }); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); @@ -176,11 +190,6 @@ createApp({ if (response.ok) status.value = await response.json(); } - async function refreshDeployment() { - const response = await apiFetch('/api/version'); - if (response.ok) deployment.value = await response.json(); - } - function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; @@ -194,7 +203,6 @@ createApp({ const message = JSON.parse(data); if (message.type === 'snapshot') { dataReceivedAt.value = Date.now(); - dataReceiptVerb.value = 'LOADED'; chartApi.setBars(message.bars); levels.value = message.levels || []; syncVisibleLevels(); @@ -207,7 +215,6 @@ createApp({ // outright. The event carries its timeframe, so honour it. if (message.tf && message.tf !== timeframe.value) return; dataReceivedAt.value = Date.now(); - dataReceiptVerb.value = 'UPDATED'; chartApi.updateBar(message.bar); price.value = message.bar.c; status.value.last_bar_t = message.bar.t; @@ -359,7 +366,7 @@ createApp({ const temporaryId = `tmp_${Date.now()}`; const optimistic = { id: temporaryId, kind: 'manual', tf: timeframe.value, side, - weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${side}`, + weight: 1, score: 1, label: drawName.value || (side === 'support' ? 'up' : 'down'), anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t), points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false, color: drawColor.value, line_width: drawWidth.value, @@ -410,6 +417,56 @@ createApp({ await deleteLines([id]); } + function shiftTimeByBars(time, count = 10) { + const bars = chartApi?.bars || []; + if (bars.length < 2) return time; + let nearest = 0; + for (let index = 1; index < bars.length; index += 1) { + if (Math.abs(bars[index].t - time) < Math.abs(bars[nearest].t - time)) nearest = index; + } + const target = nearest + count; + if (target < bars.length) return bars[target].t; + const last = bars.length - 1; + const step = bars[last].t - bars[last - 1].t; + return bars[last].t + (target - last) * step; + } + + function duplicateName(line) { + const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/); + const base = match[1]; + let number = match[2] ? Number(match[2]) + 1 : 2; + const labels = new Set(manualLines.value.map(value => value.label)); + while (labels.has(`${base} ${number}`)) number += 1; + return `${base} ${number}`; + } + + async function duplicateLine(line) { + const anchorT = shiftTimeByBars(line.anchor_t); + const endT = shiftTimeByBars(line.last_t); + const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t); + const response = await apiFetch('/api/lines', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tf: line.tf, side: line.side, + anchor_t: anchorT, anchor_p: line.anchor_p, + end_t: endT, end_p: endP, + note: duplicateName(line), hidden: line.hidden, + color: line.color || '#65b7cf', line_width: line.line_width || 2, + cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line.cutoff_t), + armed: line.armed, + }), + }); + if (!response.ok) { + console.error(`Unable to duplicate line: HTTP ${response.status}`); + return; + } + const saved = await response.json(); + levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; + selectedLine.value = saved.id; + selectedLines.value = [saved.id]; + syncVisibleLevels(); + } + async function deleteLines(ids) { if (!ids.length) return; const deleting = new Set(ids); @@ -474,7 +531,7 @@ createApp({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ anchor_t: line.anchor_t, anchor_p: line.anchor_p, - slope: line.slope, last_t: line.last_t, + slope: line.slope, last_t: line.last_t, cutoff_t: line.cutoff_t, }), }); if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; } @@ -560,7 +617,7 @@ createApp({ if (chartApi) chartApi.setSnap(value); }); - onMounted(() => { + onMounted(async () => { chartApi = new ConfluenceChart(); // Deliberate debug handle. Chart geometry bugs are invisible from the // outside — this is how the trendline slope was measured rather than @@ -571,12 +628,14 @@ createApp({ chartApi.setToolCompleteHandler(handleToolComplete); chartApi.setLineChangeHandler(updateLineGeometry); chartApi.setLineEndHandler(endLineHere); + chartApi.setLineDuplicateHandler(duplicateLine); + chartApi.setLineDeleteHandler(deleteLine); chartApi.setSnap(snap.value); chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment }); - refreshComments(); window.addEventListener('keydown', handleKeydown); - refreshStatus(); - refreshDeployment(); + await migrateLegacyToken(); + await refreshStatus(); + await refreshComments(); connect(); timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000); }); @@ -587,6 +646,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataAge, dataReceiptVerb, deployMeta, deploymentTitle, 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, dataUpdatedAt, 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 }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 7f18db1..34e1ff3 100644 --- a/static/chart.js +++ b/static/chart.js @@ -16,14 +16,22 @@ class ConfluenceChart { this.clickListener = null; this.selectedLineId = null; this.anchorHandles = []; + this.lineHitTarget = null; + this.draggingLine = null; + this.lineMoveListener = null; + this.lineUpListener = null; this.draggingAnchor = null; this.onLineChange = null; this.anchorMoveListener = null; this.anchorUpListener = null; this.contextMenu = null; this.contextCutoff = null; + this.contextLineId = null; + this.contextEndButton = null; this.contextListener = null; this.onLineEnd = null; + this.onLineDuplicate = null; + this.onLineDelete = null; // Tool arming: the sidebar decides what the next chart gesture creates. this.armedTool = null; this.gesture = null; @@ -191,6 +199,11 @@ class ConfluenceChart { const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); handles.classList.add('chart-handles'); handles.setAttribute('aria-hidden', 'true'); + this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.lineHitTarget.classList.add('chart-line-hit'); + this.lineHitTarget.setAttribute('hidden', ''); + this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event)); + handles.appendChild(this.lineHitTarget); for (const anchor of ['start', 'end']) { const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); handle.classList.add('chart-anchor'); @@ -213,8 +226,29 @@ class ConfluenceChart { event.stopPropagation(); this.endSelectedLineHere(); }); + this.contextEndButton = endHere; + const duplicate = document.createElement('button'); + duplicate.type = 'button'; + duplicate.dataset.action = 'duplicate'; + duplicate.textContent = 'Duplicate 10 bars right'; + duplicate.addEventListener('click', event => { + event.stopPropagation(); + const level = this.levels.find(value => value.id === this.contextLineId); + this.hideContextMenu(); + if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level }); + }); + const deleteLine = document.createElement('button'); + deleteLine.type = 'button'; + deleteLine.dataset.action = 'delete'; + deleteLine.textContent = 'Delete'; + deleteLine.addEventListener('click', event => { + event.stopPropagation(); + const id = this.contextLineId; + this.hideContextMenu(); + if (id && this.onLineDelete) this.onLineDelete(id); + }); this.contextMenu.addEventListener('click', event => event.stopPropagation()); - this.contextMenu.appendChild(endHere); + this.contextMenu.append(endHere, duplicate, deleteLine); this.overlayLayer.appendChild(this.contextMenu); this.priceTag = document.createElement('div'); this.priceTag.className = 'chart-price-tag'; @@ -254,6 +288,10 @@ class ConfluenceChart { this.anchorUpListener = event => this.finishAnchorDrag(event); window.addEventListener('pointermove', this.anchorMoveListener); window.addEventListener('pointerup', this.anchorUpListener); + this.lineMoveListener = event => this.moveLine(event); + this.lineUpListener = event => this.finishLineDrag(event); + window.addEventListener('pointermove', this.lineMoveListener); + window.addEventListener('pointerup', this.lineUpListener); this.contextListener = event => this.showContextMenu(event); el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { @@ -1017,6 +1055,10 @@ class ConfluenceChart { setLineEndHandler(handler) { this.onLineEnd = handler; } + setLineDuplicateHandler(handler) { this.onLineDuplicate = handler; } + + setLineDeleteHandler(handler) { this.onLineDelete = handler; } + setSelectedLine(id) { this.selectedLineId = id; this.hideContextMenu(); @@ -1102,6 +1144,19 @@ class ConfluenceChart { return lo + (span ? (time - bars[lo].t) / span : 0); } + timeAtIndex(index) { + const bars = this.bars; + if (bars.length < 2) return bars[0]?.t ?? 0; + if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t)); + const last = bars.length - 1; + if (index >= last) { + return Math.round(bars[last].t + (index - last) * (bars[last].t - bars[last - 1].t)); + } + const lower = Math.floor(index); + const fraction = index - lower; + return Math.round(bars[lower].t + fraction * (bars[lower + 1].t - bars[lower].t)); + } + lineData(level) { // Interpolated across bars, matching what the server scores the level at. // Doing it per second instead kicked the line upward at every session gap, @@ -1132,9 +1187,24 @@ class ConfluenceChart { ); if (!level || !this.anchorHandles.length) { this.anchorHandles.forEach(handle => handle.setAttribute('hidden', '')); + this.lineHitTarget?.setAttribute('hidden', ''); return; } - const points = this.lineData(level).slice(0, 2); + const data = this.lineData(level); + const points = data.slice(0, 2); + const hitPoints = [data[0], data[data.length - 1]].map(point => ({ + x: this.chart.timeScale().timeToCoordinate(point.time), + y: this.candles.priceToCoordinate(point.value), + })); + if (hitPoints.some(point => point.x == null || point.y == null)) { + this.lineHitTarget.setAttribute('hidden', ''); + } else { + this.lineHitTarget.removeAttribute('hidden'); + this.lineHitTarget.setAttribute('x1', hitPoints[0].x); + this.lineHitTarget.setAttribute('y1', hitPoints[0].y); + this.lineHitTarget.setAttribute('x2', hitPoints[1].x); + this.lineHitTarget.setAttribute('y2', hitPoints[1].y); + } points.forEach((point, index) => { const x = this.chart.timeScale().timeToCoordinate(point.time); const y = this.candles.priceToCoordinate(point.value); @@ -1150,6 +1220,57 @@ class ConfluenceChart { }); } + startLineDrag(event) { + if (event.button !== 0) return; + const level = this.levels.find(value => value.id === this.selectedLineId); + const point = this.eventPoint(event); + if (!level || !point || point.t == null) return; + event.preventDefault(); + event.stopPropagation(); + this.draggingLine = { + id: level.id, + start: point, + original: { ...level }, + moved: false, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + } + + moveLine(event) { + if (!this.draggingLine) return; + const point = this.eventPoint(event); + const level = this.levels.find(value => value.id === this.draggingLine.id); + if (!point || point.t == null || !this.withinPlot(point) || !level) return; + event.preventDefault(); + const { original, start } = this.draggingLine; + const indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t)); + const priceShift = ConfluenceChart.snapToTick(point.p - start.p); + const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); + const lastT = this.timeAtIndex(this.indexAt(original.last_t) + indexShift); + const endPrice = original.anchor_p + + original.slope * (original.last_t - original.anchor_t) + priceShift; + level.anchor_t = anchorT; + level.last_t = lastT; + level.anchor_p = original.anchor_p + priceShift; + level.slope = (endPrice - level.anchor_p) / (lastT - anchorT); + if (original.cutoff_t != null) { + level.cutoff_t = this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift); + } + this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; + const entry = this.levelSeries.get(level.id); + if (entry) entry.series.setData(this.lineData(level)); + this.renderAnchorHandles(); + } + + finishLineDrag(event) { + if (!this.draggingLine) return; + event.preventDefault(); + const level = this.levels.find(value => value.id === this.draggingLine.id); + const moved = this.draggingLine.moved; + this.draggingLine = null; + if (moved && level && this.onLineChange) this.onLineChange({ ...level }); + } + startAnchorDrag(event, anchor) { event.preventDefault(); event.stopPropagation(); @@ -1208,20 +1329,19 @@ class ConfluenceChart { const cutoff = this.bars.reduce((nearest, bar) => Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest ).t; - if (cutoff <= level.last_t) { - this.hideContextMenu(); - return; - } - this.contextCutoff = cutoff; + this.contextLineId = level.id; + this.contextCutoff = cutoff > level.last_t ? cutoff : null; + this.contextEndButton.hidden = this.contextCutoff == null; this.contextMenu.hidden = false; const plot = this.plotCanvas().getBoundingClientRect(); - this.contextMenu.style.left = `${Math.min(point.x, plot.width - 170)}px`; - this.contextMenu.style.top = `${Math.min(point.y, plot.height - 40)}px`; + this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`; + this.contextMenu.style.top = `${Math.max(4, Math.min(point.y, plot.height - this.contextMenu.offsetHeight - 4))}px`; } hideContextMenu() { if (this.contextMenu) this.contextMenu.hidden = true; this.contextCutoff = null; + this.contextLineId = null; } endSelectedLineHere() { @@ -1255,6 +1375,8 @@ class ConfluenceChart { if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener); + if (this.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener); + if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener); if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener); if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener); if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener); diff --git a/static/index.html b/static/index.html index 2c0538b..8cda64e 100644 --- a/static/index.html +++ b/static/index.html @@ -32,8 +32,7 @@ Drag on the chart from one point to the other Click or drag on the chart to set the price Line selected — Delete removes it - {{ dataReceiptVerb }} {{ dataAge }} - DEPLOY {{ deployMeta }} + UPDATED {{ dataUpdatedAt }}