/* ============================================================ main.js — app wiring. Builds the control panel from controls.js, reads params, drives generate→render, and handles exports. The preview is always the photographic renderer; SVG/PDF are the vector path. ============================================================ */ import { cyrb53 } from './rng.js'; import { generateScene } from './scene/scene.js'; import { renderCanvasPhoto } from './render/canvasPhoto.js'; import { renderSVG } from './render/svgVector.js'; import { buildPDF } from './render/pdf.js'; import { GROUPS, TOGGLES, FIXED, PRESETS } from './ui/controls.js'; import { paramsFromSeed } from './scene/params.js'; const PREVIEW = 1000; // internal preview resolution const EXPORT_PNG = 7200; // hi-res raster — 24" @ 300 DPI /* ---------- build the panel ---------- */ const panel = document.getElementById('controls'); const sliderDefs = {}; const toggleDefs = {}; function buildPanel() { for (const g of GROUPS) { const grp = el('div', 'group'); grp.appendChild(el('div', 'group-title', g.title)); for (const c of g.controls) { sliderDefs[c.id] = c; const row = el('div', 'row'); row.appendChild(el('label', null, c.label)); const val = el('span', 'val'); val.id = c.id + 'Val'; row.appendChild(val); grp.appendChild(row); const input = document.createElement('input'); input.type = 'range'; input.id = c.id; input.min = c.min; input.max = c.max; input.step = c.step; input.value = c.value; grp.appendChild(input); } // toggles belonging to a group title go after Film & Plate / Shock panel.appendChild(grp); if (g.title === 'Shock-wave Disk') addToggle(grp, 'shock'); if (g.title === 'Film & Plate') { addToggle(grp, 'showFiducials'); addToggle(grp, 'showBoundary'); addToggle(grp, 'showHeader'); addToggle(grp, 'invert'); } } } function addToggle(grp, id) { const def = TOGGLES.find(t => t.id === id); toggleDefs[id] = def; const row = el('label', 'checkbox-row'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.id = id; cb.checked = def.value; const span = el('span', null, def.label); row.appendChild(cb); row.appendChild(span); grp.appendChild(row); } function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } /* ---------- params ---------- */ function readParams() { const p = { ...FIXED, seed: document.getElementById('seedInput').value || 'DEFAULT' }; for (const [id, def] of Object.entries(sliderDefs)) { const v = parseFloat(document.getElementById(id).value); p[id] = def.int ? Math.round(v) : v; } for (const id of Object.keys(toggleDefs)) p[id] = document.getElementById(id).checked; return p; } function updateLabels() { for (const [id, def] of Object.entries(sliderDefs)) { const v = parseFloat(document.getElementById(id).value); document.getElementById(id + 'Val').textContent = def.int ? String(Math.round(v)) : v.toFixed(2); } } /* ---------- render loop ---------- */ const canvas = document.getElementById('preview'); canvas.width = canvas.height = PREVIEW; const ctx = canvas.getContext('2d', { willReadFrequently: true }); let scene = null, params = null, timer = null; function regen() { params = readParams(); scene = generateScene(params); renderCanvasPhoto(ctx, PREVIEW, PREVIEW, scene, params, { preview: true }); document.getElementById('hashDisplay').textContent = scene.hash.toUpperCase(); document.getElementById('plateNum').textContent = scene.plate; document.getElementById('exposureDate').textContent = scene.exposure; document.getElementById('labName').textContent = scene.lab; } function rerender() { if (!scene) return regen(); params = readParams(); renderCanvasPhoto(ctx, PREVIEW, PREVIEW, scene, params, { preview: true }); } function schedule(needsRegen) { clearTimeout(timer); timer = setTimeout(() => (needsRegen ? regen() : rerender()), 28); } /* ---------- wiring ---------- */ function wire() { for (const [id, def] of Object.entries(sliderDefs)) { document.getElementById(id).addEventListener('input', () => { updateLabels(); schedule(def.mode === 'scene'); }); } for (const [id, def] of Object.entries(toggleDefs)) { document.getElementById(id).addEventListener('change', () => schedule(def.mode === 'scene')); } document.getElementById('seedInput').addEventListener('change', regen); document.getElementById('regen').addEventListener('click', regen); document.getElementById('fromSeed').addEventListener('click', deriveFromSeed); document.getElementById('randomSeed').addEventListener('click', randomSeed); // presets const ps = document.getElementById('presets'); for (const name of Object.keys(PRESETS)) { const o = document.createElement('option'); o.value = name; o.textContent = name; ps.appendChild(o); } ps.addEventListener('change', () => { applyPreset(ps.value); ps.selectedIndex = 0; }); document.getElementById('exportSVG').addEventListener('click', exportSVG); document.getElementById('exportPDF').addEventListener('click', exportPDF); document.getElementById('exportPNG').addEventListener('click', exportPNG); } const WORDS = ['MUON', 'KAON', 'PION', 'LAMBDA', 'SIGMA', 'XI', 'OMEGA', 'TAU', 'GLUON', 'QUARK', 'HADRON', 'BARYON', 'LEPTON', 'NEUTRINO', 'BOSON']; function randomSeed() { const w = WORDS[Math.floor(Math.random() * WORDS.length)]; const n = Math.floor(Math.random() * 9000 + 1000); document.getElementById('seedInput').value = `${w}-${n}`; regen(); } function applyParams(obj) { for (const [k, v] of Object.entries(obj)) { if (k === 'seed') { document.getElementById('seedInput').value = v; continue; } const sl = document.getElementById(k); if (!sl) continue; // ignore derived-only keys (archetype, shockX…) if (sl.type === 'checkbox') sl.checked = !!v; else sl.value = v; } updateLabels(); regen(); } function applyPreset(name) { if (PRESETS[name]) applyParams(PRESETS[name]); } function deriveFromSeed() { const seed = document.getElementById('seedInput').value || 'ENTROPY-001'; applyParams(paramsFromSeed(seed)); } /* ---------- exports ---------- */ function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 1800); } function download(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function exportSVG() { if (!scene) regen(); download(new Blob([renderSVG(scene, params, 4800)], { type: 'image/svg+xml' }), `bubble-chamber-${params.seed}.svg`); showToast('SVG exported'); } function exportPDF() { if (!scene) regen(); download(new Blob([buildPDF(scene, params)], { type: 'application/pdf' }), `bubble-chamber-${params.seed}.pdf`); showToast('PDF exported'); } function exportPNG() { if (!scene) regen(); showToast('Rendering hi-res…'); setTimeout(() => { const off = document.createElement('canvas'); off.width = off.height = EXPORT_PNG; const octx = off.getContext('2d', { willReadFrequently: true }); renderCanvasPhoto(octx, EXPORT_PNG, EXPORT_PNG, scene, params, { preview: false }); off.toBlob(b => { download(b, `bubble-chamber-${params.seed}.png`); showToast('PNG exported'); }, 'image/png'); }, 50); } /* ---------- init ---------- */ function init() { buildPanel(); wire(); updateLabels(); regen(); // exposure date & lab now derive deterministically from the seed } init();