/* === MeshCore Analyzer — app.js === */ 'use strict'; // --- Route/Payload name maps --- const ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' }; const PAYLOAD_TYPES = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 10: 'Multipart', 11: 'Control', 15: 'Raw Custom' }; const PAYLOAD_COLORS = { 0: 'req', 1: 'response', 2: 'txt-msg', 3: 'ack', 4: 'advert', 5: 'grp-txt', 7: 'anon-req', 8: 'path', 9: 'trace' }; function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; } function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; } function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; } // --- Utilities --- const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 }; const _apiCache = new Map(); const _inflight = new Map(); // Client-side TTLs (ms) — loaded from server config, with defaults const CLIENT_TTL = { stats: 10000, nodeDetail: 240000, nodeHealth: 240000, nodeList: 90000, bulkHealth: 300000, networkStatus: 300000, observers: 120000, channels: 15000, channelMessages: 10000, analyticsRF: 300000, analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000, analyticsSubpaths: 300000, analyticsSubpathDetail: 300000, nodeAnalytics: 60000, nodeSearch: 10000 }; // Fetch server cache config and use as client TTLs (server values are in seconds) fetch('/api/config/cache').then(r => r.json()).then(cfg => { for (const [k, v] of Object.entries(cfg)) { if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000; } }).catch(() => {}); async function api(path, { ttl = 0, bust = false } = {}) { const t0 = performance.now(); if (!bust && ttl > 0) { const cached = _apiCache.get(path); if (cached && Date.now() < cached.expires) { _apiPerf.calls++; _apiPerf.cacheHits++; _apiPerf.log.push({ path, ms: 0, time: Date.now(), cached: true }); if (_apiPerf.log.length > 200) _apiPerf.log.shift(); return cached.data; } } // Deduplicate in-flight requests if (_inflight.has(path)) return _inflight.get(path); const promise = (async () => { const res = await fetch('/api' + path); if (!res.ok) throw new Error(`API ${res.status}: ${path}`); const data = await res.json(); const ms = performance.now() - t0; _apiPerf.calls++; _apiPerf.totalMs += ms; _apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() }); if (_apiPerf.log.length > 200) _apiPerf.log.shift(); if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`); if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl }); return data; })(); _inflight.set(path, promise); promise.finally(() => _inflight.delete(path)); return promise; } function invalidateApiCache(prefix) { for (const key of _apiCache.keys()) { if (key.startsWith(prefix || '')) _apiCache.delete(key); } } // Expose for console debugging: apiPerf() window.apiPerf = function() { const byPath = {}; _apiPerf.log.forEach(e => { if (!byPath[e.path]) byPath[e.path] = { count: 0, totalMs: 0, maxMs: 0 }; byPath[e.path].count++; byPath[e.path].totalMs += e.ms; if (e.ms > byPath[e.path].maxMs) byPath[e.path].maxMs = e.ms; }); const rows = Object.entries(byPath).map(([p, s]) => ({ path: p, count: s.count, avgMs: Math.round(s.totalMs / s.count), maxMs: s.maxMs, totalMs: Math.round(s.totalMs) })).sort((a, b) => b.totalMs - a.totalMs); console.table(rows); const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0; const misses = _apiPerf.calls - _apiPerf.cacheHits; console.log(`Cache: ${_apiPerf.cacheHits} hits / ${misses} misses (${hitRate}% hit rate)`); return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (misses || 1)), cacheHits: _apiPerf.cacheHits, cacheMisses: misses, cacheHitRate: hitRate, endpoints: rows }; }; function timeAgo(iso) { if (!iso) return '—'; const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; } function truncate(str, len) { if (!str) return ''; return str.length > len ? str.slice(0, len) + '…' : str; } // --- Favorites --- const FAV_KEY = 'meshcore-favorites'; function getFavorites() { try { return JSON.parse(localStorage.getItem(FAV_KEY) || '[]'); } catch { return []; } } function isFavorite(pubkey) { return getFavorites().includes(pubkey); } function toggleFavorite(pubkey) { const favs = getFavorites(); const idx = favs.indexOf(pubkey); if (idx >= 0) favs.splice(idx, 1); else favs.push(pubkey); localStorage.setItem(FAV_KEY, JSON.stringify(favs)); return idx < 0; // true if now favorited } function favStar(pubkey, cls) { const on = isFavorite(pubkey); return ''; } function bindFavStars(container, onToggle) { container.querySelectorAll('.fav-star').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const pk = btn.dataset.fav; const nowOn = toggleFavorite(pk); btn.textContent = nowOn ? '★' : '☆'; btn.classList.toggle('on', nowOn); btn.title = nowOn ? 'Remove from favorites' : 'Add to favorites'; if (onToggle) onToggle(pk, nowOn); }); }); } function formatHex(hex) { if (!hex) return ''; return hex.match(/.{1,2}/g).join(' '); } function createColoredHexDump(hex, ranges) { if (!hex || !ranges || !ranges.length) return `${formatHex(hex)}`; const bytes = hex.match(/.{1,2}/g) || []; // Build per-byte class map; later ranges override earlier const classMap = new Array(bytes.length).fill(''); const LABEL_CLASS = { 'Header': 'hex-header', 'Path Length': 'hex-pathlen', 'Transport Codes': 'hex-transport', 'Path': 'hex-path', 'Payload': 'hex-payload', 'PubKey': 'hex-pubkey', 'Timestamp': 'hex-timestamp', 'Signature': 'hex-signature', 'Flags': 'hex-flags', 'Latitude': 'hex-location', 'Longitude': 'hex-location', 'Name': 'hex-name', }; for (const r of ranges) { const cls = LABEL_CLASS[r.label] || 'hex-payload'; for (let i = r.start; i <= Math.min(r.end, bytes.length - 1); i++) classMap[i] = cls; } let html = '', prevCls = null; for (let i = 0; i < bytes.length; i++) { const cls = classMap[i]; if (cls !== prevCls) { if (prevCls !== null) html += ''; html += ``; prevCls = cls; } else { html += ' '; } html += bytes[i]; } if (prevCls !== null) html += ''; return html; } function buildHexLegend(ranges) { if (!ranges || !ranges.length) return ''; const LABEL_CLASS = { 'Header': 'hex-header', 'Path Length': 'hex-pathlen', 'Transport Codes': 'hex-transport', 'Path': 'hex-path', 'Payload': 'hex-payload', 'PubKey': 'hex-pubkey', 'Timestamp': 'hex-timestamp', 'Signature': 'hex-signature', 'Flags': 'hex-flags', 'Latitude': 'hex-location', 'Longitude': 'hex-location', 'Name': 'hex-name', }; const BG_COLORS = { 'hex-header': '#f38ba8', 'hex-pathlen': '#fab387', 'hex-transport': '#89b4fa', 'hex-path': '#a6e3a1', 'hex-payload': '#f9e2af', 'hex-pubkey': '#f9e2af', 'hex-timestamp': '#fab387', 'hex-signature': '#f38ba8', 'hex-flags': '#94e2d5', 'hex-location': '#89b4fa', 'hex-name': '#cba6f7', }; const seen = new Set(); let html = ''; for (const r of ranges) { if (seen.has(r.label)) continue; seen.add(r.label); const cls = LABEL_CLASS[r.label] || 'hex-payload'; const bg = BG_COLORS[cls] || '#f9e2af'; html += `${r.label}`; } return html; } // --- WebSocket --- let ws = null; let wsListeners = []; function connectWS() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}`); ws.onopen = () => document.getElementById('liveDot')?.classList.add('connected'); ws.onclose = () => { document.getElementById('liveDot')?.classList.remove('connected'); setTimeout(connectWS, 3000); }; ws.onerror = () => ws.close(); ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); // Debounce cache invalidation — don't nuke on every packet if (!api._invalidateTimer) { api._invalidateTimer = setTimeout(() => { api._invalidateTimer = null; invalidateApiCache('/stats'); invalidateApiCache('/nodes'); }, 5000); } wsListeners.forEach(fn => fn(msg)); } catch {} }; } function onWS(fn) { wsListeners.push(fn); } function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); } /* Global escapeHtml — used by multiple pages */ function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } /* Global debounce */ function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } /* Debounced WS helper — batches rapid messages, calls fn with array of msgs */ function debouncedOnWS(fn, ms) { if (typeof ms === 'undefined') ms = 250; let pending = []; let timer = null; function handler(msg) { pending.push(msg); if (!timer) { timer = setTimeout(function () { const batch = pending; pending = []; timer = null; fn(batch); }, ms); } } onWS(handler); return handler; // caller stores this to pass to offWS() in destroy } // --- Router --- const pages = {}; function registerPage(name, mod) { pages[name] = mod; } let currentPage = null; function navigate() { const hash = location.hash.replace('#/', '') || 'packets'; const route = hash.split('?')[0]; // Handle parameterized routes: nodes/ → nodes page + select let basePage = route; let routeParam = null; const slashIdx = route.indexOf('/'); if (slashIdx > 0) { basePage = route.substring(0, slashIdx); routeParam = decodeURIComponent(route.substring(slashIdx + 1)); } // Special route: nodes/PUBKEY/analytics → node-analytics page if (basePage === 'nodes' && routeParam && routeParam.endsWith('/analytics')) { basePage = 'node-analytics'; } // Special route: packet/123 → standalone packet detail page if (basePage === 'packet' && routeParam) { basePage = 'packet-detail'; } // Special route: observers/ID → observer detail page if (basePage === 'observers' && routeParam) { basePage = 'observer-detail'; } // Update nav active state document.querySelectorAll('.nav-link[data-route]').forEach(el => { el.classList.toggle('active', el.dataset.route === basePage); }); if (currentPage && pages[currentPage]?.destroy) { pages[currentPage].destroy(); } currentPage = basePage; const app = document.getElementById('app'); if (pages[basePage]?.init) { const t0 = performance.now(); pages[basePage].init(app, routeParam); const ms = performance.now() - t0; if (ms > 100) console.warn(`[SLOW PAGE] ${basePage} init took ${Math.round(ms)}ms`); app.classList.remove('page-enter'); void app.offsetWidth; app.classList.add('page-enter'); } else { app.innerHTML = `

${route}

Page not yet implemented.

`; } } window.addEventListener('hashchange', navigate); let _themeRefreshTimer = null; window.addEventListener('theme-changed', () => { if (_themeRefreshTimer) clearTimeout(_themeRefreshTimer); _themeRefreshTimer = setTimeout(() => { _themeRefreshTimer = null; window.dispatchEvent(new CustomEvent('theme-refresh')); }, 300); }); window.addEventListener('DOMContentLoaded', () => { connectWS(); // --- Dark Mode --- const darkToggle = document.getElementById('darkModeToggle'); const savedTheme = localStorage.getItem('meshcore-theme'); function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️'; localStorage.setItem('meshcore-theme', theme); // Re-apply user theme CSS vars for the correct mode (light/dark) reapplyUserThemeVars(theme === 'dark'); } function reapplyUserThemeVars(dark) { try { var userTheme = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); if (!userTheme.theme && !userTheme.themeDark) { // Fall back to server config var cfg = window.SITE_CONFIG || {}; if (!cfg.theme && !cfg.themeDark) return; userTheme = cfg; } var themeData = dark ? Object.assign({}, userTheme.theme || {}, userTheme.themeDark || {}) : (userTheme.theme || {}); if (!Object.keys(themeData).length) return; var varMap = { accent: '--accent', accentHover: '--accent-hover', navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted', background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border', statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red', surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3', cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg', rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg', selectedBg: '--selected-bg', sectionBg: '--section-bg', font: '--font', mono: '--mono' }; var root = document.documentElement.style; for (var key in varMap) { if (themeData[key]) root.setProperty(varMap[key], themeData[key]); } if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background); if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1); // Nav gradient if (themeData.navBg) { var nav = document.querySelector('.top-nav'); if (nav) { nav.style.background = ''; void nav.offsetHeight; } } } catch (e) { console.error('[theme] reapply error:', e); } } // On load: respect saved pref, else OS pref, else light if (savedTheme) { applyTheme(savedTheme); } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { applyTheme('dark'); } else { applyTheme('light'); } darkToggle.addEventListener('click', () => { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; applyTheme(isDark ? 'light' : 'dark'); }); // --- Hamburger Menu --- const hamburger = document.getElementById('hamburger'); const navLinks = document.querySelector('.nav-links'); hamburger.addEventListener('click', () => navLinks.classList.toggle('open')); // Close menu on nav link click navLinks.querySelectorAll('.nav-link').forEach(link => { link.addEventListener('click', () => navLinks.classList.remove('open')); }); // --- Favorites dropdown --- const favToggle = document.getElementById('favToggle'); const favDropdown = document.getElementById('favDropdown'); let favOpen = false; favToggle.addEventListener('click', (e) => { e.stopPropagation(); favOpen = !favOpen; if (favOpen) { renderFavDropdown(); favDropdown.classList.add('open'); } else { favDropdown.classList.remove('open'); } }); document.addEventListener('click', (e) => { if (favOpen && !e.target.closest('.nav-fav-wrap')) { favOpen = false; favDropdown.classList.remove('open'); } }); async function renderFavDropdown() { const favs = getFavorites(); if (!favs.length) { favDropdown.innerHTML = '
No favorites yet.
Click ☆ on any node to add it.
'; return; } favDropdown.innerHTML = '
Loading...
'; const items = await Promise.all(favs.map(async (pk) => { try { const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth }); const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null; const status = age === null ? '🔴' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? '🟢' : age < HEALTH_THRESHOLDS.nodeSilentMs ? '🟡' : '🔴'; return '' + '' + status + '' + '' + (h.node.name || truncate(pk, 12)) + '' + '' + (h.stats.lastHeard ? timeAgo(h.stats.lastHeard) : 'never') + '' + favStar(pk, 'fav-dd-star') + ''; } catch { return '' + '' + '' + truncate(pk, 16) + '' + 'not found' + favStar(pk, 'fav-dd-star') + ''; } })); favDropdown.innerHTML = items.join(''); bindFavStars(favDropdown, () => renderFavDropdown()); // Close dropdown on link click favDropdown.querySelectorAll('.fav-dd-item').forEach(a => { a.addEventListener('click', (e) => { if (e.target.closest('.fav-star')) { e.preventDefault(); return; } favOpen = false; favDropdown.classList.remove('open'); }); }); } // --- Search --- const searchToggle = document.getElementById('searchToggle'); const searchOverlay = document.getElementById('searchOverlay'); const searchInput = document.getElementById('searchInput'); const searchResults = document.getElementById('searchResults'); let searchTimeout = null; searchToggle.addEventListener('click', () => { searchOverlay.classList.toggle('hidden'); if (!searchOverlay.classList.contains('hidden')) { searchInput.value = ''; searchResults.innerHTML = ''; searchInput.focus(); } }); searchOverlay.addEventListener('click', (e) => { if (e.target === searchOverlay) searchOverlay.classList.add('hidden'); }); document.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); searchOverlay.classList.remove('hidden'); searchInput.value = ''; searchResults.innerHTML = ''; searchInput.focus(); } if (e.key === 'Escape') searchOverlay.classList.add('hidden'); }); searchInput.addEventListener('input', () => { clearTimeout(searchTimeout); const q = searchInput.value.trim(); if (!q) { searchResults.innerHTML = ''; return; } searchTimeout = setTimeout(async () => { try { const [packets, nodes, channels] = await Promise.all([ fetch('/api/packets?limit=5&hash=' + encodeURIComponent(q)).then(r => r.json()).catch(() => ({ packets: [] })), fetch('/api/nodes?search=' + encodeURIComponent(q)).then(r => r.json()).catch(() => []), fetch('/api/channels').then(r => r.json()).catch(() => []) ]); let html = ''; const pktList = packets.packets || packets; if (Array.isArray(pktList)) { for (const p of pktList.slice(0, 5)) { html += `
Packet${truncate(p.packet_hash || '', 16)} — ${payloadTypeName(p.payload_type)}
`; } } const nodeList = Array.isArray(nodes) ? nodes : (nodes.nodes || []); for (const n of nodeList.slice(0, 5)) { if (n.name && n.name.toLowerCase().includes(q.toLowerCase())) { html += `
Node${n.name} — ${truncate(n.public_key || '', 16)}
`; } } const chList = Array.isArray(channels) ? channels : []; for (const c of chList) { if (c.name && c.name.toLowerCase().includes(q.toLowerCase())) { html += `
Channel${c.name}
`; } } if (!html) html = '
No results found
'; searchResults.innerHTML = html; } catch { searchResults.innerHTML = '
Search error
'; } }, 300); }); // --- Login --- // (removed — no auth yet) // --- Nav Stats --- async function updateNavStats() { try { const stats = await api('/stats', { ttl: CLIENT_TTL.stats }); const el = document.getElementById('navStats'); if (el) { el.innerHTML = `${stats.totalPackets} pkts · ${stats.totalNodes} nodes · ${stats.totalObservers} obs`; el.querySelectorAll('.stat-val').forEach(s => s.classList.add('updated')); setTimeout(() => { el.querySelectorAll('.stat-val').forEach(s => s.classList.remove('updated')); }, 600); } } catch {} } updateNavStats(); setInterval(updateNavStats, 15000); debouncedOnWS(function () { updateNavStats(); }); // --- Theme Customization --- // Fetch theme config and apply branding/colors before first render fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => { window.SITE_CONFIG = cfg; // User's localStorage preferences take priority over server config const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })(); // Apply CSS variable overrides from theme config (skipped if user has local overrides) if (!userTheme.theme && !userTheme.themeDark) { const dark = document.documentElement.getAttribute('data-theme') === 'dark' || (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {}); const root = document.documentElement.style; const varMap = { accent: '--accent', accentHover: '--accent-hover', navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted', background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border', statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red', surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3', cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg', rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg', selectedBg: '--selected-bg', sectionBg: '--section-bg', font: '--font', mono: '--mono' }; for (const [key, cssVar] of Object.entries(varMap)) { if (themeData[key]) root.setProperty(cssVar, themeData[key]); } // Derived vars if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background); if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1); // Nav gradient if (themeData.navBg) { const nav = document.querySelector('.top-nav'); if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`; } } // Apply node color overrides (skip if user has local preferences) if (cfg.nodeColors && !userTheme.nodeColors) { for (const [role, color] of Object.entries(cfg.nodeColors)) { if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color; if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color; } } // Apply type color overrides (skip if user has local preferences) if (cfg.typeColors && !userTheme.typeColors) { for (const [type, color] of Object.entries(cfg.typeColors)) { if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color; } if (window.syncBadgeColors) window.syncBadgeColors(); } // Apply branding (skip if user has local preferences) if (cfg.branding && !userTheme.branding) { if (cfg.branding.siteName) { document.title = cfg.branding.siteName; const brandText = document.querySelector('.brand-text'); if (brandText) brandText.textContent = cfg.branding.siteName; } if (cfg.branding.logoUrl) { const brandIcon = document.querySelector('.brand-icon'); if (brandIcon) { const img = document.createElement('img'); img.src = cfg.branding.logoUrl; img.alt = cfg.branding.siteName || 'Logo'; img.style.height = '24px'; img.style.width = 'auto'; brandIcon.replaceWith(img); } } if (cfg.branding.faviconUrl) { const favicon = document.querySelector('link[rel="icon"]'); if (favicon) favicon.href = cfg.branding.faviconUrl; } } }).catch(() => { window.SITE_CONFIG = null; }).finally(() => { if (!location.hash || location.hash === '#/') location.hash = '#/home'; else navigate(); }); }); /** * Reusable ARIA tab-bar initialiser. * Adds role="tablist" to container, role="tab" + aria-selected to each button, * and arrow-key navigation between tabs. * @param {HTMLElement} container - the tab bar element * @param {Function} [onChange] - optional callback(activeBtn) on tab change */ function initTabBar(container, onChange) { if (!container || container.getAttribute('role') === 'tablist') return; container.setAttribute('role', 'tablist'); const tabs = Array.from(container.querySelectorAll('button, [data-tab], [data-obs]')); tabs.forEach(btn => { btn.setAttribute('role', 'tab'); const isActive = btn.classList.contains('active'); btn.setAttribute('aria-selected', String(isActive)); btn.setAttribute('tabindex', isActive ? '0' : '-1'); // Link to panel if aria-controls target exists const panelId = btn.dataset.tab || btn.dataset.obs; if (panelId && document.getElementById(panelId)) { btn.setAttribute('aria-controls', panelId); } }); container.addEventListener('click', (e) => { const btn = e.target.closest('[role="tab"]'); if (!btn || !container.contains(btn)) return; tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); }); btn.setAttribute('aria-selected', 'true'); btn.setAttribute('tabindex', '0'); if (onChange) onChange(btn); }); container.addEventListener('keydown', (e) => { const btn = e.target.closest('[role="tab"]'); if (!btn) return; let idx = tabs.indexOf(btn), next = -1; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % tabs.length; else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + tabs.length) % tabs.length; else if (e.key === 'Home') next = 0; else if (e.key === 'End') next = tabs.length - 1; if (next < 0) return; e.preventDefault(); tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); }); tabs[next].setAttribute('aria-selected', 'true'); tabs[next].setAttribute('tabindex', '0'); tabs[next].focus(); tabs[next].click(); }); } /** * Make table columns resizable with drag handles. Widths saved to localStorage. * Call after table is in DOM. Re-call safe (idempotent per table). * @param {string} tableSelector - CSS selector for the table * @param {string} storageKey - localStorage key for persisted widths */ function makeColumnsResizable(tableSelector, storageKey) { const table = document.querySelector(tableSelector); if (!table) return; const thead = table.querySelector('thead'); if (!thead) return; const ths = Array.from(thead.querySelectorAll('tr:first-child th')); if (ths.length < 2) return; if (table.dataset.resizable) return; table.dataset.resizable = '1'; table.style.tableLayout = 'fixed'; const containerW = table.parentElement.clientWidth; const saved = localStorage.getItem(storageKey); let widths; if (saved) { try { widths = JSON.parse(saved); } catch { widths = null; } // Validate: must be array of correct length with values summing to ~100 (percentages) if (widths && Array.isArray(widths) && widths.length === ths.length) { const sum = widths.reduce((s, w) => s + w, 0); if (sum > 90 && sum < 110) { // Saved percentages — apply directly table.style.tableLayout = 'fixed'; table.style.width = '100%'; ths.forEach((th, i) => { th.style.width = widths[i] + '%'; }); // Skip measurement, jump to adding handles addResizeHandles(); return; } } widths = null; // Force remeasure } if (!widths) { // Measure actual max content width per column by scanning visible rows const tbody = table.querySelector('tbody'); const rows = tbody ? Array.from(tbody.querySelectorAll('tr')).slice(0, 30) : []; // Temporarily set auto layout to measure table.style.tableLayout = 'auto'; table.style.width = 'auto'; // Remove nowrap temporarily so we get true content width const cells = table.querySelectorAll('td, th'); cells.forEach(c => { c.dataset.origWs = c.style.whiteSpace || ''; c.style.whiteSpace = 'nowrap'; }); // Measure each column's max content width across header + rows widths = ths.map((th, i) => { let maxW = th.scrollWidth; rows.forEach(row => { const td = row.children[i]; if (td) maxW = Math.max(maxW, td.scrollWidth); }); return maxW + 4; // small padding buffer }); cells.forEach(c => { c.style.whiteSpace = c.dataset.origWs || ''; delete c.dataset.origWs; }); } // Now fit to container: if total > container, squish widest first const totalNeeded = widths.reduce((s, w) => s + w, 0); const finalWidths = [...widths]; if (totalNeeded > containerW) { let excess = totalNeeded - containerW; const MIN_COL = 28; // Iteratively shave from widest columns while (excess > 0) { // Find current max width const maxW = Math.max(...finalWidths); if (maxW <= MIN_COL) break; // Find second-max to know our target const sorted = [...new Set(finalWidths)].sort((a, b) => b - a); const target = sorted.length > 1 ? Math.max(sorted[1], MIN_COL) : MIN_COL; // How many columns are at maxW? const atMax = finalWidths.filter(w => w >= maxW).length; const canShavePerCol = maxW - target; const neededPerCol = Math.ceil(excess / atMax); const shavePerCol = Math.min(canShavePerCol, neededPerCol); for (let i = 0; i < finalWidths.length; i++) { if (finalWidths[i] >= maxW) { const shave = Math.min(shavePerCol, excess); finalWidths[i] -= shave; excess -= shave; if (excess <= 0) break; } } } } else if (totalNeeded < containerW) { // Give surplus to the 2 widest columns (content-heavy ones) const surplus = containerW - totalNeeded; const indexed = finalWidths.map((w, i) => ({ w, i })).sort((a, b) => b.w - a.w); const topN = indexed.slice(0, Math.min(2, indexed.length)); const topTotal = topN.reduce((s, x) => s + x.w, 0); topN.forEach(x => { finalWidths[x.i] += Math.round(surplus * (x.w / topTotal)); }); } table.style.width = '100%'; const totalFinal = finalWidths.reduce((s, w) => s + w, 0); ths.forEach((th, i) => { th.style.width = (finalWidths[i] / totalFinal * 100) + '%'; }); addResizeHandles(); function addResizeHandles() { // Add resize handles ths.forEach((th, i) => { if (i === ths.length - 1) return; th.style.position = 'relative'; const handle = document.createElement('div'); handle.className = 'col-resize-handle'; handle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startW = th.offsetWidth; const startTableW = table.offsetWidth; handle.classList.add('active'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; function onMove(e2) { const dx = e2.clientX - startX; const newW = Math.max(50, startW + dx); const delta = newW - th.offsetWidth; if (delta === 0) return; // Steal/give space from columns to the right, proportionally const rightThs = ths.slice(i + 1); const rightWidths = rightThs.map(t => t.offsetWidth); const rightTotal = rightWidths.reduce((s, w) => s + w, 0); if (rightTotal - delta < rightThs.length * 50) return; // can't squeeze below 50px each th.style.width = newW + 'px'; const scale = (rightTotal - delta) / rightTotal; rightThs.forEach(t => { t.style.width = Math.max(50, t.offsetWidth * scale) + 'px'; }); } function onUp() { handle.classList.remove('active'); document.body.style.cursor = ''; document.body.style.userSelect = ''; // Save as percentages const tableW = table.offsetWidth; const ws = ths.map(t => (t.offsetWidth / tableW * 100)); localStorage.setItem(storageKey, JSON.stringify(ws)); // Re-apply as percentages ths.forEach((t, j) => { t.style.width = ws[j] + '%'; }); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); th.appendChild(handle); }); } // end addResizeHandles }