const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue; const defaultPrefs = { base_tf: '1m', enabled: { ma: { '1d': [10, 20, 50, 100, 200], '4h': [], '1h': [] }, manual: true, auto: false }, hidden_levels_score: false, }; createApp({ setup() { const status = ref({ stream: 'disconnected', bars_held: {} }); const price = ref(null); const storedPrefs = localStorage.getItem('chart-layer-prefs'); const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs)); const timeframe = ref(prefs.value.base_tf || '1m'); const levels = ref([]); const clusters = ref([]); const alerts = ref([]); const drawMode = ref(false); const drawSide = ref('support'); const snap = ref(true); const drawPoints = ref([]); const selectedLine = ref(null); const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d']; const now = ref(Date.now()); let chartApi = null; let socket = null; 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`; }); async function refreshStatus() { const response = await fetch('/api/status'); if (response.ok) status.value = await response.json(); } function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; socket = new WebSocket(`${protocol}://${location.host}/ws`); socket.onopen = () => { 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') { alerts.value.unshift({ at: new Date().toLocaleTimeString(), message: message.message }); alerts.value = alerts.value.slice(0, 20); playAlert(); } }; socket.onclose = () => { status.value.stream = 'disconnected'; setTimeout(connect, 2000); }; } function playAlert() { const context = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.frequency.value = 740; gain.gain.setValueAtTime(0.12, context.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.35); oscillator.connect(gain).connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.35); } 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), timeframe.value); } async function handleChartClick(param) { if (!drawMode.value) { selectedLine.value = chartApi.hitTest(param); 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: `${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, }; levels.value.push(optimistic); syncVisibleLevels(); drawPoints.value = []; drawMode.value = false; try { const response = await fetch('/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 }), }); 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; } catch (error) { levels.value = levels.value.filter(level => level.id !== temporaryId); console.error('Unable to save line', error); } syncVisibleLevels(); } async function deleteSelected() { if (!selectedLine.value) return; const id = selectedLine.value; levels.value = levels.value.filter(level => level.id !== id); selectedLine.value = null; syncVisibleLevels(); const response = await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); } function handleKeydown(event) { if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.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 }); onMounted(() => { chartApi = new ConfluenceChart(); chartApi.create(document.getElementById('chart')); chartApi.setClickHandler(handleChartClick); chartApi.setMoveHandler(handleChartMove); 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, drawSide, snap, drawPoints, selectedLine, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected }; }, }).mount('#app');