1230 lines
51 KiB
JavaScript
1230 lines
51 KiB
JavaScript
const { createApp, ref, computed, watch, onMounted, onUnmounted, nextTick } = 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 sessionOpen = 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('#80D8D8');
|
|
const drawWidth = ref(2);
|
|
const drawSide = ref('support');
|
|
const commentText = ref('');
|
|
const commentFloat = ref(false);
|
|
const selectedSymbol = ref('arrow-up');
|
|
const symbolColor = ref('#65b7cf');
|
|
const symbolScale = ref(1);
|
|
const symbolScales = [0.5, 1, 1.5, 2, 3];
|
|
const symbolPanelOpen = 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 drawingTf = ref('all');
|
|
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
|
|
const captureBusy = ref(false);
|
|
const snap = ref(true);
|
|
const selectedDrawing = ref(null);
|
|
const selectedDrawings = ref([]);
|
|
const drawingList = ref(null);
|
|
const alertPrice = ref(null);
|
|
const alertNote = ref('');
|
|
const levelColor = ref('#e0a34a');
|
|
const levelWidth = ref(2);
|
|
const alertEarlyPoints = ref(null);
|
|
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
|
|
const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false');
|
|
const optionPrefs = (() => {
|
|
try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; }
|
|
catch { return {}; }
|
|
})();
|
|
const optionExpirations = ref([]);
|
|
const optionExpiryId = ref(optionPrefs.expiryId || '');
|
|
const optionSide = ref(optionPrefs.side === 'C' ? 'C' : 'P');
|
|
const optionMode = ref(optionPrefs.mode === 'price' ? 'price' : 'delta');
|
|
const optionDeltaMin = ref(optionPrefs.deltaMin ?? 0.15);
|
|
const optionDeltaMax = ref(optionPrefs.deltaMax ?? 0.25);
|
|
const optionPriceMin = ref(optionPrefs.priceMin ?? 6);
|
|
const optionPriceMax = ref(optionPrefs.priceMax ?? 8);
|
|
const optionMin = ref(optionMode.value === 'price' ? optionPriceMin.value : optionDeltaMin.value);
|
|
const optionMax = ref(optionMode.value === 'price' ? optionPriceMax.value : optionDeltaMax.value);
|
|
const optionContracts = ref([]);
|
|
const optionUnderlying = ref(null);
|
|
const optionBusy = ref(false);
|
|
const optionError = ref('');
|
|
const optionSearched = ref(false);
|
|
const optionCopied = ref('');
|
|
let optionLoaded = false;
|
|
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
|
const drawingColorRows = [
|
|
{ id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] },
|
|
{ id: 'red', colors: ['#FAADBC', '#F45B78', '#D13F62', '#AF2850'] },
|
|
{ id: 'blue', colors: ['#A3CCFF', '#4699FE', '#1E76D8', '#0054B3'] },
|
|
{ id: 'orange', colors: ['#F8C592', '#F08A24', '#D66B12', '#B94E08'] },
|
|
{ id: 'teal', colors: ['#80D8D8', '#00B0B1', '#008E8F', '#006C6E'] },
|
|
{ id: 'purple', colors: ['#DDBCEB', '#BB79D7', '#9858B3', '#763790'] },
|
|
];
|
|
const drawingColors = drawingColorRows.flatMap(row => row.colors);
|
|
const drawingColorNames = {
|
|
'#A6D8AA': 'green1', '#4DB155': 'green2', '#258F33': 'green3', '#006D09': 'green4',
|
|
'#FAADBC': 'red1', '#F45B78': 'red2', '#D13F62': 'red3', '#AF2850': 'red4',
|
|
'#A3CCFF': 'blue1', '#4699FE': 'blue2', '#1E76D8': 'blue3', '#0054B3': 'blue4',
|
|
'#F8C592': 'orange1', '#F08A24': 'orange2', '#D66B12': 'orange3', '#B94E08': 'orange4',
|
|
'#80D8D8': 'teal1', '#00B0B1': 'teal2', '#008E8F': 'teal3', '#006C6E': 'teal4',
|
|
'#DDBCEB': 'purple1', '#BB79D7': 'purple2', '#9858B3': 'purple3', '#763790': 'purple4',
|
|
};
|
|
const drawingColorName = color => {
|
|
const hex = String(color || '').toUpperCase();
|
|
return `${drawingColorNames[hex] || 'custom'} (${hex})`;
|
|
};
|
|
const colorRowLabels = ref(Object.fromEntries(drawingColorRows.map(row => [row.id, row.id])));
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('chart-color-row-labels') || '{}');
|
|
for (const row of drawingColorRows) {
|
|
if (typeof saved[row.id] === 'string') colorRowLabels.value[row.id] = saved[row.id];
|
|
}
|
|
} catch { /* malformed UI preferences fall back to family names */ }
|
|
const drawingColorDescription = color => {
|
|
const hex = String(color || '').toUpperCase();
|
|
const row = drawingColorRows.find(value => value.colors.includes(hex));
|
|
const annotation = row && colorRowLabels.value[row.id] !== row.id
|
|
? colorRowLabels.value[row.id].trim() : '';
|
|
return { name: drawingColorName(hex), annotation };
|
|
};
|
|
const symbolChoices = [
|
|
{ icon: 'arrow-up', name: 'Up arrow' },
|
|
{ icon: 'arrow-down', name: 'Down arrow' },
|
|
{ icon: 'face-smile', name: 'Smiley' },
|
|
{ icon: 'hand-point-right', name: 'Pointing finger' },
|
|
{ icon: 'skull', name: 'Skull' },
|
|
{ icon: 'face-laugh-squint', name: 'Laughing face' },
|
|
{ icon: 'champagne-glasses', name: 'Celebrate' },
|
|
{ icon: 'arrow-left', name: 'Left arrow' },
|
|
{ icon: 'arrow-right', name: 'Right arrow' },
|
|
{ icon: 'hand', name: 'Stop' },
|
|
{ icon: 'right-to-bracket', name: 'Enter trade' },
|
|
{ icon: 'play', name: 'Go' },
|
|
];
|
|
const now = ref(Date.now());
|
|
let chartApi = null;
|
|
let socket = null;
|
|
let retryDelay = 2000;
|
|
let timer = null;
|
|
let nudgeQueue = Promise.resolve();
|
|
let drawColorInitialized = false;
|
|
|
|
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 build = ref({ commit: '', startedAt: '' });
|
|
const buildStamp = computed(() => {
|
|
const commit = (build.value.commit || 'dev').slice(0, 7);
|
|
if (!build.value.startedAt) return commit;
|
|
const at = new Date(build.value.startedAt);
|
|
if (Number.isNaN(at.getTime())) return commit;
|
|
return `${commit} ${new Intl.DateTimeFormat(undefined, {
|
|
day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit',
|
|
}).format(at)}`;
|
|
});
|
|
const quoteChange = computed(() => {
|
|
if (price.value == null || !(sessionOpen.value > 0)) return null;
|
|
const points = price.value - sessionOpen.value;
|
|
return { points, percent: points / sessionOpen.value * 100 };
|
|
});
|
|
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
|
|
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
|
|
|
|
// One list for everything drawn. Lines come from the levels socket, which
|
|
// keeps them live; comments come from the drawings endpoint.
|
|
// Ordered by price rather than by distance, so the list reads top-to-bottom
|
|
// the way the chart does. The server orders by proximity, which is what the
|
|
// alert path wants; this is a display concern only.
|
|
const clustersByPrice = computed(() =>
|
|
[...clusters.value].sort((a, b) => (b.high + b.low) / 2 - (a.high + a.low) / 2));
|
|
|
|
const drawings = computed(() => {
|
|
const lines = manualLines.value.map(line => ({
|
|
id: line.id,
|
|
number: line.number,
|
|
kind: line.slope === 0 ? 'level' : 'trendline',
|
|
label: line.label || '',
|
|
hidden: line.hidden,
|
|
line,
|
|
}));
|
|
const notes = comments.value.map(comment => ({
|
|
id: comment.id,
|
|
number: comment.number,
|
|
kind: comment.kind === 'symbol' ? 'symbol' : 'comment',
|
|
label: comment.note || '',
|
|
hidden: comment.hidden,
|
|
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;
|
|
const timeframe = item.line?.tf || item.comment?.tf || '';
|
|
if (drawingTf.value !== 'all' && timeframe !== drawingTf.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} ${timeframe} #${item.number}`.toLowerCase().includes(needle);
|
|
});
|
|
});
|
|
const allShownSelected = computed(() => filteredDrawings.value.length > 0
|
|
&& filteredDrawings.value.every(drawing => selectedDrawings.value.includes(drawing.id)));
|
|
const selectedAreHidden = computed(() => selectedDrawings.value.length > 0
|
|
&& selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden));
|
|
|
|
function addEvent(message, kind = 'info', url = null, number = null) {
|
|
events.value = [
|
|
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url, number },
|
|
...events.value,
|
|
].slice(0, 20);
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
const response = await apiFetch('/api/status');
|
|
if (response.ok) status.value = await response.json();
|
|
}
|
|
|
|
function selectedExpiration() {
|
|
return optionExpirations.value.find(row => row.id === optionExpiryId.value) || null;
|
|
}
|
|
|
|
function persistOptionPrefs() {
|
|
if (optionMode.value === 'price') {
|
|
optionPriceMin.value = optionMin.value;
|
|
optionPriceMax.value = optionMax.value;
|
|
} else {
|
|
optionDeltaMin.value = optionMin.value;
|
|
optionDeltaMax.value = optionMax.value;
|
|
}
|
|
localStorage.setItem('chart-es-options', JSON.stringify({
|
|
expiryId: optionExpiryId.value,
|
|
side: optionSide.value,
|
|
mode: optionMode.value,
|
|
deltaMin: optionDeltaMin.value,
|
|
deltaMax: optionDeltaMax.value,
|
|
priceMin: optionPriceMin.value,
|
|
priceMax: optionPriceMax.value,
|
|
}));
|
|
}
|
|
|
|
async function onOptionsToggle(event) {
|
|
if (!event.target.open || optionLoaded) return;
|
|
optionLoaded = true;
|
|
optionError.value = '';
|
|
const response = await apiFetch('/api/es-options/expirations');
|
|
if (!response.ok) {
|
|
optionError.value = 'Could not load expirations';
|
|
optionLoaded = false;
|
|
return;
|
|
}
|
|
const payload = await response.json();
|
|
optionExpirations.value = payload.expirations || [];
|
|
if (!optionExpirations.value.some(row => row.id === optionExpiryId.value)) {
|
|
optionExpiryId.value = optionExpirations.value[0]?.id || '';
|
|
}
|
|
}
|
|
|
|
async function searchOptions() {
|
|
const expiry = selectedExpiration();
|
|
if (!expiry || optionBusy.value) return;
|
|
optionBusy.value = true;
|
|
optionError.value = '';
|
|
optionCopied.value = '';
|
|
try {
|
|
const params = new URLSearchParams({
|
|
date: expiry.date,
|
|
root: expiry.root,
|
|
side: optionSide.value,
|
|
mode: optionMode.value,
|
|
min: String(optionMin.value),
|
|
max: String(optionMax.value),
|
|
});
|
|
const response = await apiFetch(`/api/es-options/search?${params}`);
|
|
if (!response.ok) {
|
|
const detail = await response.json().catch(() => ({}));
|
|
optionError.value = detail.detail || 'Search failed';
|
|
optionContracts.value = [];
|
|
return;
|
|
}
|
|
const payload = await response.json();
|
|
optionContracts.value = payload.contracts || [];
|
|
optionUnderlying.value = payload.underlying_price ?? null;
|
|
optionSearched.value = true;
|
|
} finally {
|
|
optionBusy.value = false;
|
|
}
|
|
}
|
|
|
|
async function copyOption(row) {
|
|
const text = row.tos;
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
throw new Error('no clipboard api');
|
|
}
|
|
} catch {
|
|
const input = document.createElement('textarea');
|
|
input.value = text;
|
|
input.setAttribute('readonly', '');
|
|
input.style.position = 'fixed';
|
|
input.style.left = '-9999px';
|
|
document.body.appendChild(input);
|
|
input.select();
|
|
const ok = document.execCommand('copy');
|
|
input.remove();
|
|
if (!ok) {
|
|
optionError.value = 'Copy failed';
|
|
return;
|
|
}
|
|
}
|
|
optionCopied.value = row.symbol;
|
|
}
|
|
|
|
watch(optionMode, (mode, previous) => {
|
|
if (previous === 'delta') {
|
|
optionDeltaMin.value = optionMin.value;
|
|
optionDeltaMax.value = optionMax.value;
|
|
} else if (previous === 'price') {
|
|
optionPriceMin.value = optionMin.value;
|
|
optionPriceMax.value = optionMax.value;
|
|
}
|
|
if (mode === 'price') {
|
|
optionMin.value = optionPriceMin.value;
|
|
optionMax.value = optionPriceMax.value;
|
|
} else {
|
|
optionMin.value = optionDeltaMin.value;
|
|
optionMax.value = optionDeltaMax.value;
|
|
}
|
|
});
|
|
watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs);
|
|
|
|
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.setTrendlineGeometry(message.trendline_geometry);
|
|
chartApi.setBars(message.bars);
|
|
levels.value = message.levels || [];
|
|
if (!drawColorInitialized) {
|
|
const latest = levels.value
|
|
.filter(level => level.kind === 'manual' && level.slope !== 0)
|
|
.sort((a, b) => (b.number || 0) - (a.number || 0))[0];
|
|
drawColor.value = latest?.color || '#80D8D8';
|
|
drawColorInitialized = true;
|
|
}
|
|
syncVisibleLevels();
|
|
price.value = message.price;
|
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
|
} 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();
|
|
if (message.trendline_first_t != null) {
|
|
const geometryCurrent = chartApi.updateTrendlineTime(
|
|
message.bar.tf, message.bar.t, null, message.trendline_first_t,
|
|
message.trendline_previous_t,
|
|
message.trendline_future_times, message.trendline_future_durations,
|
|
);
|
|
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
|
}
|
|
}
|
|
chartApi.updateBar(message.bar);
|
|
price.value = message.bar.c;
|
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
|
status.value.last_bar_t = message.bar.t;
|
|
} else if (message.type === 'trendline_bar') {
|
|
const geometryCurrent = chartApi.updateTrendlineTime(
|
|
message.tf, message.t, message.duration, message.first_t, message.previous_t,
|
|
message.future_times, message.future_durations,
|
|
);
|
|
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
|
}
|
|
syncVisibleLevels();
|
|
} else if (message.type === 'trendline_geometry') {
|
|
chartApi.setTrendlineGeometry(message.geometry);
|
|
syncVisibleLevels();
|
|
} 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()];
|
|
if (message.trendline_geometry) chartApi.setTrendlineGeometry(message.trendline_geometry);
|
|
syncVisibleLevels();
|
|
} else if (message.type === 'clusters') {
|
|
clusters.value = message.clusters;
|
|
price.value = message.price;
|
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
|
} 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.
|
|
// The server numbers alerts; the client's own counter restarts on
|
|
// reload and differs per tab, so it cannot match a phone.
|
|
addEvent(message.message, 'alert', null, message.number || null);
|
|
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;
|
|
if (tool !== 'symbol') symbolPanelOpen.value = false;
|
|
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) {
|
|
// Placement is one-shot even though the symbol palette remains open. The
|
|
// next gesture should select or adjust the mark, not stamp another one.
|
|
armedTool.value = null;
|
|
chartApi.armTool(null);
|
|
if (result.tool === 'comment') {
|
|
await createComment(result);
|
|
return;
|
|
}
|
|
if (result.tool === 'symbol') {
|
|
await createSymbol(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 => ['comment', 'symbol'].includes(item.kind));
|
|
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 createSymbol(result, icon = selectedSymbol.value) {
|
|
const choice = symbolChoices.find(symbol => symbol.icon === icon);
|
|
if (!choice) return;
|
|
const response = await apiFetch('/api/comments', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text: choice.name, icon, color: symbolColor.value, scale: symbolScale.value, pinned: true,
|
|
anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
console.error('Unable to save symbol', response.status);
|
|
return;
|
|
}
|
|
await refreshComments();
|
|
}
|
|
|
|
function chooseSymbol(symbol) {
|
|
selectedSymbol.value = symbol.icon;
|
|
armedTool.value = 'symbol';
|
|
chartApi.armTool('symbol');
|
|
}
|
|
|
|
function toggleSymbolPanel() {
|
|
symbolPanelOpen.value = !symbolPanelOpen.value;
|
|
if (symbolPanelOpen.value) {
|
|
armedTool.value = 'symbol';
|
|
chartApi.armTool('symbol');
|
|
} else if (armedTool.value === 'symbol') {
|
|
armedTool.value = null;
|
|
chartApi.armTool(null);
|
|
}
|
|
}
|
|
|
|
function startSymbolDrag(event, symbol) {
|
|
selectedSymbol.value = symbol.icon;
|
|
event.dataTransfer.effectAllowed = 'copy';
|
|
event.dataTransfer.setData('text/x-chart-symbol', symbol.icon);
|
|
}
|
|
|
|
async function dropSymbol(event) {
|
|
const icon = event.dataTransfer.getData('text/x-chart-symbol');
|
|
const result = chartApi?.placementAtClient(event.clientX, event.clientY);
|
|
if (!icon || !result) return;
|
|
await createSymbol(result, icon);
|
|
if (armedTool.value === 'symbol') {
|
|
armedTool.value = null;
|
|
chartApi.armTool(null);
|
|
}
|
|
}
|
|
|
|
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, changes) => patchComment(comment, changes);
|
|
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
|
|
|
|
async function deleteDrawing(item) {
|
|
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
|
|
if (item.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 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,
|
|
alert_early_points: alertEarlyPoints.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 = '';
|
|
alertEarlyPoints.value = null;
|
|
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(line, time, count = 10) {
|
|
return chartApi?.shiftLineTime(line, time, count) ?? time;
|
|
}
|
|
|
|
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) {
|
|
if (line.geometry_resolved === false) return;
|
|
const anchorT = shiftTimeByBars(line, line.anchor_t);
|
|
const endT = shiftTimeByBars(line, 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, 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 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;
|
|
if (selectedDrawing.value === id) {
|
|
const item = drawings.value.find(drawing => drawing.id === id);
|
|
if (item?.comment) chartApi?.focusComment(id);
|
|
else chartApi?.focusLine(id);
|
|
}
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const shown = new Set(filteredDrawings.value.map(drawing => drawing.id));
|
|
selectedDrawings.value = allShownSelected.value
|
|
? selectedDrawings.value.filter(id => !shown.has(id))
|
|
: [...new Set([...selectedDrawings.value, ...shown])];
|
|
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
|
|
}
|
|
|
|
async function toggleSelectedVisibility() {
|
|
const hidden = !selectedAreHidden.value;
|
|
for (const id of [...selectedDrawings.value]) {
|
|
const item = drawings.value.find(drawing => drawing.id === id);
|
|
const drawing = item?.line || item?.comment;
|
|
if (drawing) await updateLineStyle(drawing, { hidden });
|
|
}
|
|
}
|
|
|
|
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 (['comment', 'symbol'].includes(line.kind)) { await refreshComments(); return; }
|
|
levels.value = levels.value.map(level => level.id === line.id ? saved : level);
|
|
syncVisibleLevels();
|
|
}
|
|
|
|
async function updateLevelNumber(line, field, raw) {
|
|
const value = raw === '' ? null : Number(raw);
|
|
if (field === 'anchor_p' && !(value > 0)) return;
|
|
if (field === 'alert_early_points' && value != null && value < 0) return;
|
|
await updateLineStyle(line, { [field]: value });
|
|
}
|
|
|
|
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 updateCommentGeometry(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) { console.error(`Unable to move drawing: HTTP ${response.status}`); return; }
|
|
const saved = await response.json();
|
|
comments.value = comments.value.map(value => value.id === comment.id ? saved : value);
|
|
syncComments();
|
|
}
|
|
|
|
async function nudgeSelectedDrawings(key, multiplier) {
|
|
const ids = selectedDrawings.value.length
|
|
? [...selectedDrawings.value] : [selectedDrawing.value].filter(Boolean);
|
|
if (!ids.length || !chartApi) return;
|
|
const vertical = key === 'ArrowUp' ? multiplier : key === 'ArrowDown' ? -multiplier : 0;
|
|
const horizontal = key === 'ArrowRight' ? multiplier : key === 'ArrowLeft' ? -multiplier : 0;
|
|
for (const id of ids) {
|
|
const item = drawings.value.find(value => value.id === id);
|
|
if (!item) continue;
|
|
if (item.line) {
|
|
const line = { ...item.line };
|
|
if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK);
|
|
if (line.slope === 0) {
|
|
if (vertical) await updateLineGeometry(line);
|
|
continue;
|
|
}
|
|
const endPrice = item.line.anchor_p
|
|
+ item.line.slope * (item.line.last_t - item.line.anchor_t)
|
|
+ vertical * ConfluenceChart.TICK;
|
|
if (horizontal) {
|
|
if (line.geometry_resolved === false) continue;
|
|
line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
|
|
line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal);
|
|
if (line.cutoff_t != null) {
|
|
line.cutoff_t = chartApi.shiftLineTime(line, line.cutoff_t, horizontal);
|
|
}
|
|
}
|
|
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
|
|
await updateLineGeometry(line);
|
|
continue;
|
|
}
|
|
const comment = item.comment;
|
|
if (comment.pinned) {
|
|
const changes = {};
|
|
if (vertical) {
|
|
changes.anchor_p = ConfluenceChart.snapToTick(
|
|
comment.anchor_p + vertical * ConfluenceChart.TICK,
|
|
);
|
|
}
|
|
if (horizontal) {
|
|
changes.anchor_t = chartApi.timeAtIndex(
|
|
chartApi.indexAt(comment.anchor_t) + horizontal,
|
|
);
|
|
}
|
|
if (Object.keys(changes).length) await updateCommentGeometry(comment, changes);
|
|
} else {
|
|
if (!chartApi.chartEl) continue;
|
|
const pixels = 4 * multiplier;
|
|
const clamp = value => Math.min(Math.max(value, 0), 1);
|
|
await updateCommentGeometry(comment, {
|
|
x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0)
|
|
/ chartApi.chartEl.clientWidth),
|
|
y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0)
|
|
/ chartApi.chartEl.clientHeight),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (target.isContentEditable || ['TEXTAREA', 'SELECT'].includes(target.tagName)) return true;
|
|
return target.tagName === 'INPUT' && target.type !== 'checkbox';
|
|
}
|
|
|
|
function handleKeydown(event) {
|
|
if (event.key === 'Escape') {
|
|
const palettes = [...document.querySelectorAll('.color-picker[open]')];
|
|
palettes.forEach(palette => { palette.open = false; });
|
|
const dismissedContext = chartApi?.dismissContextMenu();
|
|
if (armedTool.value) {
|
|
armedTool.value = null;
|
|
symbolPanelOpen.value = false;
|
|
chartApi.armTool(null);
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (palettes.length || dismissedContext || symbolPanelOpen.value) {
|
|
symbolPanelOpen.value = false;
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (hasDrawingSelection.value) {
|
|
selectedDrawing.value = null;
|
|
selectedDrawings.value = [];
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (isEditing(event.target)) return;
|
|
if (event.key.startsWith('Arrow') && hasDrawingSelection.value) {
|
|
event.preventDefault();
|
|
const multiplier = event.shiftKey ? 4 : 1;
|
|
nudgeQueue = nudgeQueue.then(() => nudgeSelectedDrawings(event.key, multiplier));
|
|
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 });
|
|
|
|
function startDrawingListResize(event) {
|
|
const el = drawingList.value;
|
|
if (!el) return;
|
|
event.preventDefault();
|
|
const origin = event.clientY;
|
|
const start = el.getBoundingClientRect().height;
|
|
const move = ev => {
|
|
el.style.height = `${Math.max(88, Math.round(start + ev.clientY - origin))}px`;
|
|
};
|
|
const stop = () => {
|
|
window.removeEventListener('pointermove', move);
|
|
window.removeEventListener('pointerup', stop);
|
|
const height = Math.round(el.getBoundingClientRect().height);
|
|
if (height > 80) localStorage.setItem('chart-drawing-list-height', String(height));
|
|
};
|
|
window.addEventListener('pointermove', move);
|
|
window.addEventListener('pointerup', stop);
|
|
}
|
|
|
|
function revealSelectedDrawing(id) {
|
|
if (!id) return;
|
|
const list = drawingList.value;
|
|
const section = list?.closest('details');
|
|
if (section) section.open = true;
|
|
nextTick(() => {
|
|
const row = list?.querySelector(`[data-drawing-id="${CSS.escape(id)}"]`);
|
|
row?.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
watch(selectedDrawing, id => {
|
|
if (chartApi) {
|
|
chartApi.setSelectedDrawing(id);
|
|
chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
|
|
}
|
|
revealSelectedDrawing(id);
|
|
});
|
|
|
|
watch(snap, value => {
|
|
if (chartApi) chartApi.setSnap(value);
|
|
});
|
|
|
|
watch(colorRowLabels, value => {
|
|
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
|
|
}, { deep: true });
|
|
|
|
watch(animateCurrentPrice, value => {
|
|
localStorage.setItem('chart-animate-current-price', String(value));
|
|
if (chartApi) chartApi.setCurrentPriceAnimation(value);
|
|
});
|
|
|
|
watch(autoScrollLivePrice, value => {
|
|
localStorage.setItem('chart-auto-scroll-live-price', String(value));
|
|
if (chartApi) chartApi.setAutoScrollLivePrice(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(line => {
|
|
nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line));
|
|
});
|
|
chartApi.setDrawingColorDescriptor(drawingColorDescription);
|
|
chartApi.setLineEndHandler(endLineHere);
|
|
chartApi.setLineDuplicateHandler(duplicateLine);
|
|
chartApi.setLineDeleteHandler(deleteLine);
|
|
chartApi.setSnap(snap.value);
|
|
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
|
|
chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value);
|
|
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
|
window.addEventListener('keydown', handleKeydown);
|
|
nextTick(() => {
|
|
const el = drawingList.value;
|
|
const saved = Number(localStorage.getItem('chart-drawing-list-height'));
|
|
if (el && saved > 80) el.style.height = `${saved}px`;
|
|
});
|
|
await migrateLegacyToken();
|
|
await refreshStatus();
|
|
await refreshComments();
|
|
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
|
|
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
|
|
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, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption };
|
|
},
|
|
}).mount('#app');
|