Cannot update oldest data appeared in the console once ticks were live. 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, so a 1m bar can arrive after the 1h snapshot. Against the 1h series it is older than every point in it, and Lightweight Charts throws rather than ignoring it, which takes the app down instead of dropping one bar. The race predates the tick feed. Level 1 made bar events about fifteen times more frequent, which is what surfaced it. Guarded at both ends. app.js honours the tf each event already carries and drops anything for a timeframe no longer selected. chart.js refuses a bar older than the series' last point whatever its origin, since a bar behind the last one has nothing to contribute. Verified: 36 rapid timeframe switches under a live tick feed produce zero console errors, and calling candles.update() directly with a stale bar still throws while the guarded updateBar() does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
451 lines
18 KiB
JavaScript
451 lines
18 KiB
JavaScript
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] }, 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 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;
|
|
// 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 snap = ref(true);
|
|
const selectedLine = ref(null);
|
|
const selectedLines = ref([]);
|
|
const alertPrice = ref(null);
|
|
const alertNote = ref('');
|
|
const levelColor = ref('#e0a34a');
|
|
const levelWidth = ref(2);
|
|
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') {
|
|
// 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;
|
|
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.
|
|
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 armTool(tool) {
|
|
armedTool.value = armedTool.value === tool ? null : tool;
|
|
selectedLine.value = null;
|
|
chartApi.armTool(armedTool.value);
|
|
}
|
|
|
|
function handleChartClick(param) {
|
|
selectedLine.value = chartApi.hitTest(param);
|
|
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
|
|
}
|
|
|
|
async function handleToolComplete(result) {
|
|
// One placement per arming, so a tool cannot keep firing on stray drags.
|
|
armedTool.value = null;
|
|
chartApi.armTool(null);
|
|
if (result.tool === 'level') {
|
|
alertPrice.value = result.price;
|
|
await addPriceAlert();
|
|
return;
|
|
}
|
|
await createTrendline(result.start, result.end);
|
|
}
|
|
|
|
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 || `${timeframe.value} ${side}`,
|
|
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);
|
|
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 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,
|
|
}),
|
|
});
|
|
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 = '';
|
|
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 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();
|
|
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;
|
|
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(selectedLine, id => {
|
|
if (chartApi) chartApi.setSelectedLine(id);
|
|
});
|
|
|
|
watch(snap, value => {
|
|
if (chartApi) chartApi.setSnap(value);
|
|
});
|
|
|
|
onMounted(() => {
|
|
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.setSnap(snap.value);
|
|
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, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed };
|
|
},
|
|
}).mount('#app');
|