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 alertEarlyPoints = ref(null); 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; let nudgeQueue = Promise.resolve(); 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 + comments.value.length > 0 && [...manualLines.value, ...comments.value] .every(drawing => selectedDrawings.value.includes(drawing.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, alert_early_points: alertEarlyPoints.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 = ''; alertEarlyPoints.value = null; 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 ? [] : drawings.value.map(drawing => drawing.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 updateLevelNumber(line, field, raw) { const value = raw === '' ? null : Number(raw); if (field === 'anchor_p' && !(value > 0)) return; if (field === 'alert_early_points' && value != null && value < 0) return; await updateLineStyle(line, { [field]: value }); } 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 updateCommentGeometry(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) { console.error(`Unable to move drawing: HTTP ${response.status}`); return; } const saved = await response.json(); comments.value = comments.value.map(value => value.id === comment.id ? saved : value); syncComments(); } async function nudgeSelectedDrawings(key, multiplier) { const ids = selectedDrawings.value.length ? [...selectedDrawings.value] : [selectedDrawing.value].filter(Boolean); if (!ids.length || !chartApi) return; const vertical = key === 'ArrowUp' ? multiplier : key === 'ArrowDown' ? -multiplier : 0; const horizontal = key === 'ArrowRight' ? multiplier : key === 'ArrowLeft' ? -multiplier : 0; for (const id of ids) { const item = drawings.value.find(value => value.id === id); if (!item) continue; if (item.line) { const line = { ...item.line }; if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK); if (line.slope === 0) { if (vertical) await updateLineGeometry(line); continue; } const endPrice = item.line.anchor_p + item.line.slope * (item.line.last_t - item.line.anchor_t) + vertical * ConfluenceChart.TICK; if (horizontal) { line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal); line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_t) + horizontal); if (line.cutoff_t != null) { line.cutoff_t = chartApi.timeAtIndex(chartApi.indexAt(line.cutoff_t) + horizontal); } } line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); await updateLineGeometry(line); continue; } const comment = item.comment; if (comment.pinned) { const changes = {}; if (vertical) { changes.anchor_p = ConfluenceChart.snapToTick( comment.anchor_p + vertical * ConfluenceChart.TICK, ); } if (horizontal) { changes.anchor_t = chartApi.timeAtIndex( chartApi.indexAt(comment.anchor_t) + horizontal, ); } if (Object.keys(changes).length) await updateCommentGeometry(comment, changes); } else { if (!chartApi.chartEl) continue; const pixels = 4 * multiplier; const clamp = value => Math.min(Math.max(value, 0), 1); await updateCommentGeometry(comment, { x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0) / chartApi.chartEl.clientWidth), y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0) / chartApi.chartEl.clientHeight), }); } } } 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.startsWith('Arrow') && hasDrawingSelection.value) { event.preventDefault(); const multiplier = event.shiftKey ? 4 : 1; nudgeQueue = nudgeQueue.then(() => nudgeSelectedDrawings(event.key, multiplier)); 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, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app');