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 }}