chart/static/app.js

782 lines
31 KiB
JavaScript

const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
// Keep an existing token long enough to migrate an already-open browser to the
// password session. Successful login removes it; new browsers never store the
// API token at all.
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;
let loginPromise = null;
async function migrateLegacyToken() {
if (!authToken) return;
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Chart-Token': authToken },
body: JSON.stringify({ password: '' }),
});
if (!response.ok) return;
authToken = '';
localStorage.removeItem(TOKEN_KEY);
}
async function runPasswordLogin() {
while (!promptDeclined) {
const entered = window.prompt('Password for this chart', '');
if (entered === null) {
promptDeclined = true;
return false;
}
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: entered }),
});
if (!response.ok) {
window.alert('Incorrect password');
continue;
}
authToken = '';
localStorage.removeItem(TOKEN_KEY);
return true;
}
return false;
}
async function loginWithPassword() {
if (promptDeclined) return false;
if (!loginPromise) {
loginPromise = runPasswordLogin().finally(() => { loginPromise = null; });
}
return loginPromise;
}
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 && await loginWithPassword()) 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 dataReceivedAt = ref(null);
const price = ref(null);
const prefs = ref(loadPrefs());
const timeframe = ref(prefs.value.base_tf || '1m');
const levels = ref([]);
const clusters = ref([]);
const events = ref([]);
let eventSequence = 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 commentText = ref('');
const commentFloat = ref(false);
// Comments are not levels, so they never arrive over the levels socket and
// are fetched and refreshed explicitly.
const comments = ref([]);
const drawingFilter = ref('');
const drawingKind = ref('all');
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false);
const snap = ref(true);
const selectedDrawing = ref(null);
const selectedDrawings = 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 drawingColors = [
'#65b7cf', '#27825c', '#45c39b', '#a8c85d',
'#efb643', '#e0a34a', '#d97a3a', '#bd4545',
'#d96073', '#b07ad6', '#7d67c9', '#4f6fae',
'#9fb0c4', '#746c60', '#2c2924', '#c8992f',
];
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 dataUpdatedAt = computed(() => {
if (dataReceivedAt.value == null) return '—';
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit', second: '2-digit',
}).format(new Date(dataReceivedAt.value));
});
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0
&& manualLines.value.every(line => selectedDrawings.value.includes(line.id)));
// One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint.
const drawings = computed(() => {
const lines = manualLines.value.map(line => ({
id: line.id,
number: line.number,
kind: line.slope === 0 ? 'level' : 'trendline',
label: line.label || '',
line,
}));
const notes = comments.value.map(comment => ({
id: comment.id,
number: comment.number,
kind: 'comment',
label: comment.note || '',
comment,
}));
return [...lines, ...notes].sort((a, b) => a.number - b.number);
});
const filteredDrawings = computed(() => {
const needle = drawingFilter.value.trim().toLowerCase();
return drawings.value.filter(item => {
if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false;
if (!needle) return true;
// Matched against what is on screen — the label or comment text, the
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle);
});
});
function addEvent(message, kind = 'info', url = null) {
events.value = [
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url },
...events.value,
].slice(0, 20);
}
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') {
dataReceivedAt.value = Date.now();
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;
dataReceivedAt.value = Date.now();
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.
addEvent(message.message, 'alert');
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 encodeCaptureMetadata(metadata) {
const bytes = new TextEncoder().encode(JSON.stringify(metadata));
return btoa([...bytes].map(byte => String.fromCharCode(byte)).join(''));
}
async function captureDiagnostic() {
if (captureBusy.value) return;
if (!navigator.mediaDevices?.getDisplayMedia) {
addEvent('CAPTURE UNAVAILABLE · use HTTPS and a supported browser', 'capture-error');
return;
}
captureBusy.value = true;
let stream = null;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' },
audio: false,
preferCurrentTab: true,
selfBrowserSurface: 'include',
});
const video = document.createElement('video');
video.muted = true;
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve;
video.onerror = reject;
video.srcObject = stream;
});
await video.play();
await new Promise(resolve => {
if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve());
else requestAnimationFrame(() => resolve());
});
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const image = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
if (!image) throw new Error('Browser did not produce a PNG');
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
const bars = chartApi?.bars || [];
const item = drawings.value.find(drawing => drawing.id === selectedDrawing.value);
const selected = item?.line || item?.comment || null;
const metadata = {
url: location.href,
timeframe: timeframe.value,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
device_pixel_ratio: window.devicePixelRatio,
image_width: canvas.width,
image_height: canvas.height,
first_bar_t: bars[0]?.t ?? null,
last_bar_t: bars[bars.length - 1]?.t ?? null,
bars_held: bars.length,
selected_drawing: selected,
latest_snap: chartApi?.lastSnapDiagnostic ?? null,
commit: version.commit ?? null,
};
const response = await apiFetch('/api/debug/captures', {
method: 'POST',
headers: {
'Content-Type': 'image/png',
'X-Capture-Metadata': encodeCaptureMetadata(metadata),
},
body: image,
});
if (!response.ok) throw new Error(`upload returned HTTP ${response.status}`);
const saved = await response.json();
addEvent(`CAPTURE ${saved.id} · ${timeframe.value}`, 'capture', saved.url);
} catch (error) {
const cancelled = error?.name === 'AbortError' || error?.name === 'NotAllowedError';
addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error');
} finally {
stream?.getTracks().forEach(track => track.stop());
captureBusy.value = false;
}
}
function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool;
selectedDrawing.value = null;
selectedDrawings.value = [];
chartApi.armTool(armedTool.value);
}
function handleChartClick(param) {
const id = chartApi.hitTest(param);
selectedDrawing.value = id;
selectedDrawings.value = id ? [id] : [];
if (id) chartApi.focusLine(id);
}
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 === 'comment') {
await createComment(result);
return;
}
if (result.tool === 'level') {
alertPrice.value = result.price;
await addPriceAlert();
return;
}
await createTrendline(result.start, result.end);
}
async function refreshComments() {
const response = await apiFetch('/api/drawings');
if (!response.ok) return;
const payload = await response.json();
comments.value = (payload.drawings || []).filter(item => item.kind === 'comment');
syncComments();
}
function syncComments() {
if (chartApi) chartApi.setComments(comments.value.filter(item => !item.hidden));
}
async function createComment(result) {
const text = commentText.value.trim();
if (!text) return;
const response = await apiFetch('/api/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
pinned: !commentFloat.value,
anchor_t: result.time ?? null,
anchor_p: result.price ?? null,
x: result.x, y: result.y,
}),
});
if (!response.ok) {
console.error('Unable to save comment', response.status);
return;
}
commentText.value = '';
await refreshComments();
}
async function patchComment(comment, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
if (response.ok) await refreshComments();
}
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
const moveComment = (comment, x, y) => patchComment(comment, { x, y });
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
async function deleteDrawing(item) {
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.kind === 'comment') await refreshComments();
else levels.value = levels.value.filter(level => level.id !== item.id);
if (selectedDrawing.value === item.id) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id);
syncVisibleLevels();
}
async function deleteFilteredDrawings() {
const doomed = [...filteredDrawings.value];
if (!doomed.length) return;
for (const item of doomed) await deleteDrawing(item);
}
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 || `${side === 'support' ? 'up' : 'down'}${timeframe.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();
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);
selectedDrawing.value = saved.id;
selectedDrawings.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 = selectedDrawings.value.length
? selectedDrawings.value : [selectedDrawing.value].filter(Boolean);
for (const id of ids) {
const item = drawings.value.find(drawing => drawing.id === id);
if (item) await deleteDrawing(item);
}
}
async function deleteLine(id) {
await deleteLines([id]);
}
function shiftTimeByBars(time, count = 10) {
const bars = chartApi?.bars || [];
if (bars.length < 2) return time;
let nearest = 0;
for (let index = 1; index < bars.length; index += 1) {
if (Math.abs(bars[index].t - time) < Math.abs(bars[nearest].t - time)) nearest = index;
}
const target = nearest + count;
if (target < bars.length) return bars[target].t;
const last = bars.length - 1;
const step = bars[last].t - bars[last - 1].t;
return bars[last].t + (target - last) * step;
}
function duplicateName(line) {
const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/);
const base = match[1];
let number = match[2] ? Number(match[2]) + 1 : 2;
const labels = new Set(manualLines.value.map(value => value.label));
while (labels.has(`${base} ${number}`)) number += 1;
return `${base} ${number}`;
}
async function duplicateLine(line) {
const anchorT = shiftTimeByBars(line.anchor_t);
const endT = shiftTimeByBars(line.last_t);
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: line.tf, side: line.side,
anchor_t: anchorT, anchor_p: line.anchor_p,
end_t: endT, end_p: endP,
note: duplicateName(line), hidden: line.hidden,
color: line.color || '#65b7cf', line_width: line.line_width || 2,
cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line.cutoff_t),
armed: line.armed,
}),
});
if (!response.ok) {
console.error(`Unable to duplicate line: HTTP ${response.status}`);
return;
}
const saved = await response.json();
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
selectedDrawing.value = saved.id;
selectedDrawings.value = [saved.id];
syncVisibleLevels();
}
async function deleteLines(ids) {
if (!ids.length) return;
const deleting = new Set(ids);
levels.value = levels.value.filter(level => !deleting.has(level.id));
if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null;
selectedDrawings.value = selectedDrawings.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 focusDrawing(item) {
selectedDrawing.value = item.id;
selectedDrawings.value = [item.id];
if (item.kind === 'comment') chartApi?.focusComment(item.id);
else chartApi?.focusLine(item.id);
}
function selectComment(comment) {
selectedDrawing.value = comment.id;
selectedDrawings.value = [comment.id];
}
function toggleDrawingSelection(id) {
selectedDrawings.value = selectedDrawings.value.includes(id)
? selectedDrawings.value.filter(value => value !== id)
: [...selectedDrawings.value, id];
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
}
function toggleSelectAll() {
selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
}
async function deleteSelectedLines() {
await deleteLines([...selectedDrawings.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();
// A comment is not a level and must not be pushed into the level list,
// where it would be drawn as a line.
if (line.kind === 'comment') { await refreshComments(); return; }
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, cutoff_t: line.cutoff_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 (event.key === 'Escape') {
const palettes = [...document.querySelectorAll('.color-picker[open]')];
if (palettes.length) {
palettes.forEach(palette => { palette.open = false; });
event.preventDefault();
return;
}
if (chartApi?.dismissContextMenu()) {
event.preventDefault();
return;
}
if (armedTool.value) {
armedTool.value = null;
chartApi.armTool(null);
event.preventDefault();
return;
}
if (hasDrawingSelection.value) {
selectedDrawing.value = null;
selectedDrawings.value = [];
event.preventDefault();
return;
}
}
if (isEditing(event.target)) return;
if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.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(selectedDrawing, id => {
if (chartApi) chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
});
watch(snap, value => {
if (chartApi) chartApi.setSnap(value);
});
onMounted(async () => {
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.setLineDuplicateHandler(duplicateLine);
chartApi.setLineDeleteHandler(deleteLine);
chartApi.setSnap(snap.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
window.addEventListener('keydown', handleKeydown);
await migrateLegacyToken();
await refreshStatus();
await refreshComments();
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, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
},
}).mount('#app');