The trendline slope fix was already correct — measured on the live chart, the two rendered segments came out at screen slopes 0.4910 and 0.4903, collinear. It still reproduced in the browser because the browser was not running it. StaticFiles sends an ETag but no Cache-Control, and the asset URLs carried no version, so nothing forced a refetch. A tab left open across an edit never fetches at all: it keeps executing the JavaScript it loaded when the page was first opened. Every fix since that tab was opened was invisible in it. The page now stamps its own asset URLs with a digest of their contents and is itself served no-store. Hashing rather than stamping mtimes, because a deploy checks every file out fresh and would otherwise invalidate assets that never changed. This also removes a trap the README already half-documented for deploys: an /api-only change leaves the HTML byte-identical, and until now a JavaScript change could leave the served page byte-identical too. The debug handle used to measure the geometry is kept deliberately. Chart rendering bugs are invisible from the outside, and window.__chart.lineData() against timeToCoordinate() is what settled this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
445 lines
18 KiB
JavaScript
445 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') {
|
|
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');
|