calculator.127local.net/public/js/app.js
2025-09-01 20:08:03 -07:00

164 lines
5.5 KiB
JavaScript

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 = `<div class="muted">Failed to load calculator. Check console.</div>`;
}
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();