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. 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; function promptForToken() { if (promptDeclined) return false; const entered = window.prompt('Access token for this chart', ''); if (entered === null) { promptDeclined = true; return false; } authToken = entered.trim(); localStorage.setItem(TOKEN_KEY, authToken); return true; } 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 && promptForToken()) return send(); return response; } const defaultPrefs = { base_tf: '1m', enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false }, 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 price = ref(null); const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); const levels = ref([]); const clusters = ref([]); const alerts = ref([]); let alertSequence = 0; const drawMode = ref(false); const drawName = ref(''); const drawColor = ref('#65b7cf'); const drawWidth = ref(2); const drawSide = ref('support'); const snap = ref(true); const drawPoints = ref([]); const selectedLine = ref(null); const selectedLines = ref([]); const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; 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 manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); 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') { chartApi.setBars(message.bars); levels.value = message.levels || []; syncVisibleLevels(); price.value = message.price; } else if (message.type === 'bar') { chartApi.updateBar(message.bar); price.value = message.bar.c; status.value.last_bar_t = message.bar.t; } else if (message.type === 'levels') { levels.value = message.levels; 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. alerts.value = [ { key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message }, ...alerts.value, ].slice(0, 20); 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 toggleDraw() { drawMode.value = !drawMode.value; drawPoints.value = []; selectedLine.value = null; chartApi.clearLinePreview(); } function handleChartMove(param) { if (!drawMode.value || drawPoints.value.length !== 1) return; chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value); } async function handleChartClick(param) { if (!drawMode.value) { selectedLine.value = chartApi.hitTest(param); selectedLines.value = selectedLine.value ? [selectedLine.value] : []; return; } const point = chartApi.pointFromClick(param, snap.value); if (point.snappedSide) drawSide.value = point.snappedSide; drawPoints.value.push(point); if (drawPoints.value.length < 2) return; chartApi.clearLinePreview(); const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t); if (start.t === end.t) { drawPoints.value = []; return; } const temporaryId = `tmp_${Date.now()}`; const optimistic = { id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value, weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${drawSide.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(); drawPoints.value = []; drawMode.value = false; try { const response = await apiFetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, 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); selectedLine.value = saved.id; selectedLines.value = [saved.id]; } catch (error) { levels.value = levels.value.filter(level => level.id !== temporaryId); console.error('Unable to save line', error); } syncVisibleLevels(); } async function deleteSelected() { const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean); await deleteLines(ids); } async function deleteLine(id) { await deleteLines([id]); } async function deleteLines(ids) { if (!ids.length) return; const deleting = new Set(ids); levels.value = levels.value.filter(level => !deleting.has(level.id)); if (selectedLine.value && deleting.has(selectedLine.value)) selectedLine.value = null; selectedLines.value = selectedLines.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 selectLine(id) { selectedLine.value = id; selectedLines.value = [id]; } function toggleLineSelection(id) { selectedLines.value = selectedLines.value.includes(id) ? selectedLines.value.filter(value => value !== id) : [...selectedLines.value, id]; selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; } function toggleSelectAll() { selectedLines.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id); selectedLine.value = selectedLines.value.length === 1 ? selectedLines.value[0] : null; } async function deleteSelectedLines() { await deleteLines([...selectedLines.value]); } 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(); 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, }), }); 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 (isEditing(event.target)) return; if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.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; 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 })); } } function allEnabled(tf) { const available = tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]; return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period)); } function toggleGroup(tf, checked) { prefs.value.enabled.ma[tf] = checked ? (tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]) : []; } watch(prefs, () => { localStorage.setItem('chart-layer-prefs', JSON.stringify(prefs.value)); syncVisibleLevels(); sendPrefs(); }, { deep: true }); watch(selectedLine, id => { if (chartApi) chartApi.setSelectedLine(id); }); onMounted(() => { chartApi = new ConfluenceChart(); chartApi.create(document.getElementById('chart')); chartApi.setClickHandler(handleChartClick); chartApi.setMoveHandler(handleChartMove); chartApi.setLineChangeHandler(updateLineGeometry); chartApi.setLineEndHandler(endLineHere); window.addEventListener('keydown', handleKeydown); refreshStatus(); 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, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; }, }).mount('#app');