const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue; const defaultPrefs = { base_tf: '1m', enabled: { ma: { '1d': [10, 20, 50, 100, 200], '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 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 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 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), 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 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, 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 fetch(`/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 fetch(`/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 fetch(`/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 fetch(`/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(); } 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 }); 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');