chart/static/app.js
Chris Amow 1a1514bc77 Add a price alert input
There was no way to say "tell me when ES reaches 7800". The only user-settable
alert was a drawn trendline, placed by clicking two points on a canvas — so you
could not hit an exact price, and making the line flat was fiddly.

A price alert is a manual line with zero slope. Reusing that rather than
building a parallel concept means it inherits JSON persistence, renaming,
recolouring, deletion, clustering, and the rule that a hand-placed level alerts
whatever its confluence score. The only genuinely new code is the input, an
endpoint that takes a price instead of two anchors, and the decision to render
zero-slope manual lines as price lines — which spans the chart and labels the
axis, instead of drawing a stubby two-point segment.

Zero-slope lines also skip drag handles, hit-testing and the "end line here"
menu: a price line has no endpoints to grab. They are managed from the sidebar.

Unlabelled alerts are named by their price, since "1d resistance" does not say
which alert fired.

Verified live: a level typed 25 points above price clusters as resistance at
that price and stays quiet, as it should until price arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:09:14 -05:00

426 lines
17 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], '1h': [] }, 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;
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 alertPrice = ref(null);
const alertNote = ref('');
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 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 apiFetch('/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 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 }),
});
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 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 }));
}
}
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, alertPrice, alertNote, addPriceAlert, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle };
},
}).mount('#app');