chart/static/app.js
Chris Amow 32e25b84aa Number alerts, stamp them locally, and compact the zone list
Alerts get a number, assigned server-side and shown in both the push and the
Events list, so a notification on a phone can be matched to a row on a screen
when several fire together. It could not come from the browser: that counter
restarts on reload and differs between tabs. It is persisted next to the
cooldown state, because numbering restarting after a deploy would collide with a
phone's existing notification history — which changed that file from a list to
an object, with the loader still reading the old shape.

Pushes now carry a timestamp in the configured zone rather than the server's.
ALERT_TIMEZONE defaults to America/Chicago; containers run UTC, and a push
reading 02:14 to someone seeing 21:14 costs a translation every time. The
browser already formats its own times locally and is unchanged.

Confluence zones are one line each, ordered by price rather than by proximity,
so the list reads top to bottom the way the chart does and all of them fit on
screen — sixteen zones in 394px, about 25px each, where each previously took a
four-line block. Ordering is a display concern only: the server still returns
them nearest-first, which is what the alert path wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:44:57 -05:00

874 lines
35 KiB
JavaScript

const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
// 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;
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;
}
return false;
}
async function loginWithPassword() {
if (promptDeclined) return false;
if (!loginPromise) {
loginPromise = runPasswordLogin().finally(() => { loginPromise = null; });
}
return loginPromise;
}
async function apiFetch(url, options = {}) {
const send = () => fetch(url, {
...options,
headers: authToken
? { ...(options.headers || {}), 'X-Chart-Token': authToken }
: { ...(options.headers || {}) },
});
const response = await send();
// A 401 means nothing was written, so retrying the same request is safe.
if (response.status === 401 && await loginWithPassword()) return send();
return response;
}
const defaultPrefs = {
base_tf: '1m',
enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, manual: true, auto: false, horizontal: true, vwap: true },
hidden_levels_score: false,
};
// Stored preferences outlive the shape they were written in. Anything added to
// defaultPrefs later would otherwise be missing for every existing visitor —
// and a missing `enabled.ma` is a crash, not a cosmetic gap.
function mergePrefs(defaults, stored) {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return structuredClone(defaults);
const merged = structuredClone(defaults);
for (const [key, value] of Object.entries(stored)) {
if (!(key in merged)) continue;
const fallback = merged[key];
if (fallback && typeof fallback === 'object' && !Array.isArray(fallback)) {
merged[key] = mergePrefs(fallback, value);
} else if (Array.isArray(fallback)) {
if (Array.isArray(value)) merged[key] = value;
} else if (value !== null && typeof value !== 'object') {
merged[key] = value;
}
}
return merged;
}
function loadPrefs() {
try {
return mergePrefs(defaultPrefs, JSON.parse(localStorage.getItem('chart-layer-prefs')));
} catch {
return structuredClone(defaultPrefs);
}
}
createApp({
setup() {
const status = ref({ stream: 'disconnected', bars_held: {} });
const dataReceivedAt = ref(null);
const price = ref(null);
const prefs = ref(loadPrefs());
const timeframe = ref(prefs.value.base_tf || '1m');
const levels = ref([]);
const clusters = ref([]);
const events = ref([]);
let eventSequence = 0;
// Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null);
const drawName = ref('');
const drawColor = ref('#65b7cf');
const drawWidth = ref(2);
const drawSide = ref('support');
const commentText = ref('');
const commentFloat = ref(false);
const selectedSymbol = ref('arrow-up');
const symbolColor = ref('#65b7cf');
const symbolPanelOpen = 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 diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false);
const snap = ref(true);
const selectedDrawing = ref(null);
const selectedDrawings = ref([]);
const alertPrice = ref(null);
const alertNote = ref('');
const levelColor = ref('#e0a34a');
const levelWidth = ref(2);
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const drawingColors = [
'#65b7cf', '#27825c', '#45c39b', '#a8c85d',
'#efb643', '#e0a34a', '#d97a3a', '#bd4545',
'#d96073', '#b07ad6', '#7d67c9', '#4f6fae',
'#9fb0c4', '#746c60', '#2c2924', '#c8992f',
];
const drawingColorNames = {
'#65b7cf': 'Cyan', '#27825c': 'Green', '#45c39b': 'Mint', '#a8c85d': 'Lime',
'#efb643': 'Amber', '#e0a34a': 'Gold', '#d97a3a': 'Orange', '#bd4545': 'Red',
'#d96073': 'Rose', '#b07ad6': 'Purple', '#7d67c9': 'Violet', '#4f6fae': 'Blue',
'#9fb0c4': 'Slate', '#746c60': 'Taupe', '#2c2924': 'Charcoal', '#c8992f': 'Ochre',
};
const drawingColorName = color => drawingColorNames[color] || color;
const symbolChoices = [
{ icon: 'arrow-up', name: 'Up arrow' },
{ icon: 'arrow-down', name: 'Down arrow' },
{ icon: 'face-smile', name: 'Smiley' },
{ icon: 'hand-point-right', name: 'Pointing finger' },
{ icon: 'skull', name: 'Skull' },
{ icon: 'face-laugh-squint', name: 'Laughing face' },
{ icon: 'champagne-glasses', name: 'Celebrate' },
{ icon: 'arrow-left', name: 'Left arrow' },
{ icon: 'arrow-right', name: 'Right arrow' },
{ icon: 'hand', name: 'Stop' },
{ icon: 'right-to-bracket', name: 'Enter trade' },
{ icon: 'play', name: 'Go' },
];
const now = ref(Date.now());
let chartApi = null;
let socket = null;
let retryDelay = 2000;
let timer = null;
const barAge = computed(() => {
if (!status.value.last_bar_t) return '—';
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 dataUpdatedAt = computed(() => {
if (dataReceivedAt.value == null) return '—';
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit', second: '2-digit',
}).format(new Date(dataReceivedAt.value));
});
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0
&& manualLines.value.every(line => selectedDrawings.value.includes(line.id)));
// One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint.
// Ordered by price rather than by distance, so the list reads top-to-bottom
// the way the chart does. The server orders by proximity, which is what the
// alert path wants; this is a display concern only.
const clustersByPrice = computed(() =>
[...clusters.value].sort((a, b) => (b.high + b.low) / 2 - (a.high + a.low) / 2));
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.kind === 'symbol' ? 'symbol' : '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);
});
});
function addEvent(message, kind = 'info', url = null, number = null) {
events.value = [
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url, number },
...events.value,
].slice(0, 20);
}
async function refreshStatus() {
const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json();
}
function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
socket = new WebSocket(`${protocol}://${location.host}/ws${query}`);
socket.onopen = () => {
retryDelay = 2000;
socket.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value }));
sendPrefs();
};
socket.onmessage = ({ data }) => {
const message = JSON.parse(data);
if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now();
chartApi.setBars(message.bars);
levels.value = message.levels || [];
syncVisibleLevels();
price.value = message.price;
} else if (message.type === 'bar') {
// Switching timeframe races: the server answers `subscribe` with a
// fresh snapshot from one coroutine while another is still draining
// bar events for the timeframe just left. A 1m bar applied over a 1h
// series is older than everything in it, which the chart rejects
// outright. The event carries its timeframe, so honour it.
if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now();
chartApi.updateBar(message.bar);
price.value = message.bar.c;
status.value.last_bar_t = message.bar.t;
} else if (message.type === 'levels') {
// A delta, not a replacement: VWAP changes every minute while the
// daily averages carry hundreds of points and change once a session.
const removed = new Set(message.removed || []);
const byId = new Map(levels.value.filter(level => !removed.has(level.id)).map(level => [level.id, level]));
for (const level of message.changed || []) byId.set(level.id, level);
levels.value = [...byId.values()];
syncVisibleLevels();
} else if (message.type === 'clusters') {
clusters.value = message.clusters;
price.value = message.price;
} else if (message.type === 'alert') {
// Keyed on a counter, not the timestamp: two alerts inside the same
// second would collide and Vue would reuse the wrong row.
// The server numbers alerts; the client's own counter restarts on
// reload and differs per tab, so it cannot match a phone.
addEvent(message.message, 'alert', null, message.number || null);
playAlert();
}
};
socket.onclose = async () => {
status.value.stream = 'disconnected';
// A refused upgrade reaches the browser as 1006, not 1008: the server
// rejects the handshake with HTTP 403 before any close frame exists.
// So the close code cannot distinguish "bad token" from "server
// restarting". Re-check over HTTP instead — apiFetch prompts for a
// token when that is what is actually wrong — and back off so a
// visitor without one is not reconnecting twice a second forever.
await refreshStatus();
retryDelay = Math.min(retryDelay * 2, 30000);
setTimeout(connect, retryDelay);
};
}
// One context for the page, not one per alert. Browsers cap how many a
// document may hold (~6), after which alerts silently stop making a sound.
let audioContext = null;
function playAlert() {
const Context = window.AudioContext || window.webkitAudioContext;
if (!Context) return;
if (!audioContext) audioContext = new Context();
// Autoplay policy suspends a context created before any user gesture.
if (audioContext.state === 'suspended') audioContext.resume();
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.frequency.value = 740;
gain.gain.setValueAtTime(0.12, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.35);
oscillator.connect(gain).connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.35);
// Nodes are single-use; release them rather than letting them pile up.
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
}
function encodeCaptureMetadata(metadata) {
const bytes = new TextEncoder().encode(JSON.stringify(metadata));
return btoa([...bytes].map(byte => String.fromCharCode(byte)).join(''));
}
async function captureDiagnostic() {
if (captureBusy.value) return;
if (!navigator.mediaDevices?.getDisplayMedia) {
addEvent('CAPTURE UNAVAILABLE · use HTTPS and a supported browser', 'capture-error');
return;
}
captureBusy.value = true;
let stream = null;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
audio: false,
preferCurrentTab: true,
selfBrowserSurface: 'include',
});
const video = document.createElement('video');
video.muted = true;
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve;
video.onerror = reject;
video.srcObject = stream;
});
await video.play();
await new Promise(resolve => {
if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve());
else requestAnimationFrame(() => resolve());
});
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const image = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
if (!image) throw new Error('Browser did not produce a PNG');
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
const bars = chartApi?.bars || [];
const item = drawings.value.find(drawing => drawing.id === selectedDrawing.value);
const selected = item?.line || item?.comment || null;
const metadata = {
url: location.href,
timeframe: timeframe.value,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
device_pixel_ratio: window.devicePixelRatio,
image_width: canvas.width,
image_height: canvas.height,
first_bar_t: bars[0]?.t ?? null,
last_bar_t: bars[bars.length - 1]?.t ?? null,
bars_held: bars.length,
selected_drawing: selected,
latest_snap: chartApi?.lastSnapDiagnostic ?? null,
commit: version.commit ?? null,
};
const response = await apiFetch('/api/debug/captures', {
method: 'POST',
headers: {
'Content-Type': 'image/png',
'X-Capture-Metadata': encodeCaptureMetadata(metadata),
},
body: image,
});
if (!response.ok) throw new Error(`upload returned HTTP ${response.status}`);
const saved = await response.json();
addEvent(`CAPTURE ${saved.id} · ${timeframe.value}`, 'capture', saved.url);
} catch (error) {
const cancelled = error?.name === 'AbortError' || error?.name === 'NotAllowedError';
addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error');
} finally {
stream?.getTracks().forEach(track => track.stop());
captureBusy.value = false;
}
}
function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool;
if (tool !== 'symbol') symbolPanelOpen.value = false;
selectedDrawing.value = null;
selectedDrawings.value = [];
chartApi.armTool(armedTool.value);
}
function handleChartClick(param) {
const id = chartApi.hitTest(param);
selectedDrawing.value = id;
selectedDrawings.value = id ? [id] : [];
if (id) chartApi.focusLine(id);
}
async function handleToolComplete(result) {
// Placement is one-shot even though the symbol palette remains open. The
// next gesture should select or adjust the mark, not stamp another one.
armedTool.value = null;
chartApi.armTool(null);
if (result.tool === 'comment') {
await createComment(result);
return;
}
if (result.tool === 'symbol') {
await createSymbol(result);
return;
}
if (result.tool === 'level') {
alertPrice.value = result.price;
await addPriceAlert();
return;
}
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 => ['comment', 'symbol'].includes(item.kind));
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 createSymbol(result, icon = selectedSymbol.value) {
const choice = symbolChoices.find(symbol => symbol.icon === icon);
if (!choice) return;
const response = await apiFetch('/api/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: choice.name, icon, color: symbolColor.value, pinned: true,
anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y,
}),
});
if (!response.ok) {
console.error('Unable to save symbol', response.status);
return;
}
await refreshComments();
}
function chooseSymbol(symbol) {
selectedSymbol.value = symbol.icon;
armedTool.value = 'symbol';
chartApi.armTool('symbol');
}
function toggleSymbolPanel() {
symbolPanelOpen.value = !symbolPanelOpen.value;
if (symbolPanelOpen.value) {
armedTool.value = 'symbol';
chartApi.armTool('symbol');
} else if (armedTool.value === 'symbol') {
armedTool.value = null;
chartApi.armTool(null);
}
}
function startSymbolDrag(event, symbol) {
selectedSymbol.value = symbol.icon;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/x-chart-symbol', symbol.icon);
}
async function dropSymbol(event) {
const icon = event.dataTransfer.getData('text/x-chart-symbol');
const result = chartApi?.placementAtClient(event.clientX, event.clientY);
if (!icon || !result) return;
await createSymbol(result, icon);
if (armedTool.value === 'symbol') {
armedTool.value = null;
chartApi.armTool(null);
}
}
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, changes) => patchComment(comment, changes);
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
async function deleteDrawing(item) {
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.comment) await refreshComments();
else levels.value = levels.value.filter(level => level.id !== item.id);
if (selectedDrawing.value === item.id) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.value.filter(id => 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) {
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
const side = start.snappedSide || end.snappedSide || drawSide.value;
const temporaryId = `tmp_${Date.now()}`;
const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side,
weight: 1, score: 1,
label: drawName.value || `${side === 'support' ? 'up' : 'down'}${timeframe.value}`,
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
color: drawColor.value, line_width: drawWidth.value,
};
levels.value.push(optimistic);
syncVisibleLevels();
try {
const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tf: timeframe.value, side, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json();
levels.value = levels.value.filter(level => level.id !== temporaryId);
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
selectedDrawing.value = saved.id;
selectedDrawings.value = [saved.id];
} catch (error) {
levels.value = levels.value.filter(level => level.id !== temporaryId);
console.error('Unable to save line', error);
}
syncVisibleLevels();
}
async function addPriceAlert() {
if (!alertPrice.value) return;
const response = await apiFetch('/api/lines/price', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
price: alertPrice.value, note: alertNote.value,
color: levelColor.value, line_width: levelWidth.value,
}),
});
if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; }
const saved = await response.json();
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
alertPrice.value = null;
alertNote.value = '';
syncVisibleLevels();
}
async function deleteSelected() {
const ids = selectedDrawings.value.length
? selectedDrawings.value : [selectedDrawing.value].filter(Boolean);
for (const id of ids) {
const item = drawings.value.find(drawing => drawing.id === id);
if (item) await deleteDrawing(item);
}
}
async function deleteLine(id) {
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];
selectedDrawing.value = saved.id;
selectedDrawings.value = [saved.id];
syncVisibleLevels();
}
async function deleteLines(ids) {
if (!ids.length) return;
const deleting = new Set(ids);
levels.value = levels.value.filter(level => !deleting.has(level.id));
if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.value.filter(id => !deleting.has(id));
syncVisibleLevels();
const responses = [];
for (const id of ids) {
responses.push(await apiFetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }));
}
responses.forEach(response => {
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
});
}
function focusDrawing(item) {
selectedDrawing.value = item.id;
selectedDrawings.value = [item.id];
if (item.comment) chartApi?.focusComment(item.id);
else chartApi?.focusLine(item.id);
}
function selectComment(comment) {
selectedDrawing.value = comment.id;
selectedDrawings.value = [comment.id];
}
function toggleDrawingSelection(id) {
selectedDrawings.value = selectedDrawings.value.includes(id)
? selectedDrawings.value.filter(value => value !== id)
: [...selectedDrawings.value, id];
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
}
function toggleSelectAll() {
selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
}
async function deleteSelectedLines() {
await deleteLines([...selectedDrawings.value]);
}
async function setArmed(line, armed) {
await updateLineStyle(line, { armed });
}
async function renameLine(line, name) {
await updateLineStyle(line, { note: name.trim() });
}
async function updateLineStyle(line, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
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 (['comment', 'symbol'].includes(line.kind)) { await refreshComments(); return; }
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels();
}
async function updateLineGeometry(line) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
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, cutoff_t: line.cutoff_t,
}),
});
if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; }
const saved = await response.json();
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels();
}
async function endLineHere(line) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cutoff_t: line.cutoff_t }),
});
if (!response.ok) { console.error(`Unable to end line: HTTP ${response.status}`); return; }
const saved = await response.json();
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels();
}
// Backspace is a normal editing key inside a field. Without this guard,
// fixing a typo in a trendline's name deletes the trendline.
function isEditing(target) {
if (!target) return false;
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
}
function handleKeydown(event) {
if (event.key === 'Escape') {
const palettes = [...document.querySelectorAll('.color-picker[open]')];
if (palettes.length) {
palettes.forEach(palette => { palette.open = false; });
event.preventDefault();
return;
}
if (chartApi?.dismissContextMenu()) {
event.preventDefault();
return;
}
if (armedTool.value) {
armedTool.value = null;
chartApi.armTool(null);
event.preventDefault();
return;
}
if (hasDrawingSelection.value) {
selectedDrawing.value = null;
selectedDrawings.value = [];
event.preventDefault();
return;
}
}
if (isEditing(event.target)) return;
if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) {
event.preventDefault();
deleteSelected();
}
}
function selectTimeframe(tf) {
timeframe.value = tf;
prefs.value.base_tf = tf;
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'subscribe', tf }));
}
}
function enabled(level) {
if (level.kind === 'ma') return (prefs.value.enabled.ma[level.tf] || []).includes(level.period);
if (level.kind === 'manual') return prefs.value.enabled.manual;
if (level.kind === 'horizontal') return prefs.value.enabled.horizontal;
if (level.kind === 'vwap') return prefs.value.enabled.vwap;
return prefs.value.enabled.auto;
}
function syncVisibleLevels() {
if (chartApi) chartApi.syncLevels(levels.value.filter(enabled));
}
function sendPrefs() {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'prefs', ...prefs.value }));
}
}
const MA_PERIODS = { '1d': [10, 20, 50, 100, 200] };
function allEnabled(tf) {
const available = MA_PERIODS[tf] || [];
return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period));
}
function toggleGroup(tf, checked) {
prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : [];
}
watch(prefs, () => {
localStorage.setItem('chart-layer-prefs', JSON.stringify(prefs.value));
syncVisibleLevels();
sendPrefs();
}, { deep: true });
watch(selectedDrawing, id => {
if (chartApi) {
chartApi.setSelectedDrawing(id);
chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
}
});
watch(snap, value => {
if (chartApi) chartApi.setSnap(value);
});
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
// guessed at: __chart.lineData(level) against timeToCoordinate().
window.__chart = chartApi;
chartApi.create(document.getElementById('chart'));
chartApi.setClickHandler(handleChartClick);
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, onSelect: selectComment });
window.addEventListener('keydown', handleKeydown);
await migrateLegacyToken();
await refreshStatus();
await refreshComments();
connect();
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
});
onUnmounted(() => {
clearInterval(timer);
if (socket) socket.close();
if (chartApi) chartApi.destroy();
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
},
}).mount('#app');