import {el, initTheme, enhanceSelects} from './util.js'; const CALCS = [ { id:'interest', name:'Interest (Simple & Compound)', about:'Simple/compound interest', path:'../calculators/interest.js' }, { id:'raid', name:'RAID', about:'Usable capacity', path:'../calculators/raid.js' }, { id:'bandwidth', name:'Bandwidth', about:'Bits↔bytes unit conv.', path:'../calculators/bandwidth.js' }, { id:'nmea', name:'NMEA', about:'0183 XOR checksum', path:'../calculators/nmea.js' }, { id:'currency', name:'Currency Converter', about:'Convert between currencies', path:'../calculators/currency.js' }, { id:'subnet', name:'IP Subnet', about:'IPv4/IPv6 subnet calculations', path:'../calculators/subnet.js' } ]; const navEl = document.getElementById('nav'); const viewEl = document.getElementById('view'); const themeBtn= document.getElementById('themeToggle'); initTheme(themeBtn); const moduleCache = new Map(); const viewCache = new Map(); function metaById(id){ return CALCS.find(c=>c.id===id) || CALCS[0] } /* ---------- History API routing (path + query) ---------- */ function normalize(id, params){ const qs = params && [...params].length ? `?${params.toString()}` : ''; return `/${id}${qs}`; } function parseRoute(){ const path = location.pathname.replace(/^\/+/, ''); const id = path || 'interest'; const params = new URLSearchParams(location.search); return { id, params }; } function setRoute(id, params, {replace=true}={}){ const target = normalize(id, params || new URLSearchParams()); const current = `${location.pathname}${location.search}`; if (target === current) return; // no-op if unchanged if (replace) history.replaceState(null, '', target); else history.pushState(null, '', target); mount(); // re-render } function formToParams(card){ const p = new URLSearchParams(); card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{ let v = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value; if (v !== '') p.set(el.name, v); }); return p; } function paramsToForm(card, params){ let touched = false; card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{ const k = el.name; if (!params.has(k)) return; const v = params.get(k); if (el.type === 'checkbox'){ const val = (v==='1' || v==='true'); if(el.checked !== val){ el.checked = val; touched = true; } }else{ if(el.value !== v){ el.value = v; touched = true; } } }); if (touched) card.dispatchEvent(new Event('input', {bubbles:true})); } /* ---------- Nav: anchors + minimal history ---------- */ function renderNav(active){ navEl.innerHTML = ''; const ul = el('ul',{class:'navlist'}); for(const c of CALCS){ const a = el('a', { href: `/${c.id}`, 'data-calc': c.id, 'aria-current': c.id===active ? 'page' : null }, c.name); ul.append(el('li',{}, a)); } navEl.append(ul); } // delegate clicks so we can decide push vs replace navEl.addEventListener('click', (e)=>{ const a = e.target.closest('a[data-calc]'); if(!a) return; // allow new tab/window/defaults if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); const id = a.dataset.calc; // shift-click = commit to history (push); otherwise replace (minimal history) setRoute(id, null, {replace: !e.shiftKey}); }); function loadModule(spec){ if(!moduleCache.has(spec)) moduleCache.set(spec, import(spec)); return moduleCache.get(spec); } async function ensureMounted(id){ if(viewCache.has(id)) return viewCache.get(id); const meta = metaById(id); const card = el('section',{class:'card'}); viewCache.set(id, card); // placeholder heading card.append(el('h2',{}, meta.name), el('div',{class:'muted'}, meta.about||'')); try{ const mod = await loadModule(meta.path); const calc = mod.default; card.innerHTML = ''; card.append(el('h2',{}, calc.name || meta.name)); if(calc.about) card.append(el('div',{class:'muted'}, calc.about)); calc.render(card); enhanceSelects(card); }catch(e){ console.error(e); card.innerHTML = `
Failed to load calculator. Check console.
`; } return card; } function attachUrlSync(card, id){ // Debounced replaceState on input changes (never pushes to history) let t; card.addEventListener('input', ()=>{ clearTimeout(t); t = setTimeout(()=>{ const newParams = formToParams(card); const {id:curId, params:curParams} = parseRoute(); const sameId = (curId === id); const sameQs = sameId && newParams.toString() === curParams.toString(); if(!sameQs) setRoute(id, newParams, {replace:true}); }, 150); }); } async function show(id, params){ renderNav(id); for(const [cid, card] of viewCache.entries()){ card.style.display = (cid===id) ? '' : 'none'; } if(!viewCache.has(id)){ const card = await ensureMounted(id); viewEl.append(card); attachUrlSync(card, id); } paramsToForm(viewCache.get(id), params); } async function mount(){ const {id, params} = parseRoute(); // normalize root to /interest once if(!location.pathname || location.pathname === '/' ){ setRoute('interest', params, {replace:true}); return; } await show(id, params); // idle prefetch const idle = window.requestIdleCallback || ((fn)=>setTimeout(fn,300)); idle(()=> CALCS.forEach(c=> loadModule(c.path).catch(()=>{}))); } addEventListener('popstate', mount); // back/forward mount();