208 lines
7.9 KiB
JavaScript
208 lines
7.9 KiB
JavaScript
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;
|
|
}
|
|
|
|
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;
|
|
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);
|
|
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');
|