web interface

This commit is contained in:
2026-06-02 19:17:19 -04:00
parent 52453fba67
commit 219eb6632c
140 changed files with 2793 additions and 40 deletions

246
composer.html Normal file
View File

@@ -0,0 +1,246 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<title>Bubble Chamber · Composer</title>
<style>
:root{ --bg:#15140f; --panel:#1d1b15; --line:#34302433; --ink:#cabfa6; --dim:#7c7460; --accent:#9fb7af; }
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);font:12px/1.45 ui-monospace,Menlo,monospace}
#app{display:grid;grid-template-columns:340px 1fr;height:100vh}
/* sidebar */
#side{background:var(--panel);overflow-y:auto;border-right:1px solid #000}
.bar{display:flex;gap:6px;padding:10px;position:sticky;top:0;background:var(--panel);border-bottom:1px solid #000;z-index:2;flex-wrap:wrap}
.bar button,.bar label.btn{background:#2a2820;color:var(--ink);border:1px solid #3a362a;border-radius:4px;padding:5px 9px;cursor:pointer;font:11px ui-monospace}
.bar button:hover{background:#34302440}
.title{letter-spacing:.2em;text-transform:uppercase;color:var(--accent);font-size:11px;width:100%;margin:0 0 2px}
/* accordion */
.grp{border-bottom:1px solid #000}
.ghead{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;user-select:none}
.ghead:hover{background:#ffffff08}
.ghead.active{background:#9fb7af14}
.ghead .nm{flex:1;letter-spacing:.08em}
.ghead .ar{color:var(--dim)}
.gbody{display:none;padding:6px 12px 14px}
.grp.open .gbody{display:block}
.grp.open .ar{transform:rotate(90deg)}
.sub{color:var(--dim);text-transform:uppercase;letter-spacing:.12em;font-size:10px;margin:10px 0 4px}
.ctl{display:grid;grid-template-columns:1fr 116px 40px;align-items:center;gap:7px;margin:3px 0}
.ctl label{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ctl input[type=range]{width:100%}
.ctl .val{color:var(--dim);text-align:right}
.ctl input[type=text],.ctl select{grid-column:2/4;background:#100f0b;color:var(--ink);border:1px solid #3a362a;border-radius:3px;padding:3px}
.ctl input[type=color]{grid-column:2/4;width:100%;height:22px;background:none;border:1px solid #3a362a}
.dot{width:13px;height:13px;border-radius:50%;border:1px solid #0008;cursor:pointer}
.swadge{font-size:10px;color:var(--dim)}
/* stage */
#main{position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#0c0b08}
#stagewrap{position:relative;box-shadow:0 0 80px #000a, 0 0 0 1px #000}
#stage{display:block;touch-action:none}
#stage svg{display:block;width:min(86vh,calc(100vw - 380px));height:auto}
#marks{position:absolute;inset:0;pointer-events:none}
.mk{position:absolute;transform:translate(-50%,-50%);width:16px;height:16px;border-radius:50%;border:1.5px solid #fff8;mix-blend-mode:difference;pointer-events:none}
.mk.on{border-color:#fff;width:20px;height:20px}
.mk b{position:absolute;left:120%;top:50%;transform:translateY(-50%);font:10px ui-monospace;color:#fff;white-space:nowrap;mix-blend-mode:difference}
#status{position:absolute;left:12px;bottom:10px;color:var(--dim);font-size:11px}
#busy{position:absolute;right:12px;top:10px;color:var(--accent);font-size:11px;opacity:0;transition:opacity .15s}
</style></head>
<body>
<div id="app">
<div id="side">
<div class="bar">
<p class="title">Bubble Chamber · Composer</p>
<button id="exportSvg">Export SVG</button>
<button id="exportCfg">Export .mjs</button>
<label class="btn">Load…<input id="loadCfg" type="file" accept=".mjs,.js,.json" hidden></label>
<button id="reseed" title="new random event seed">Reseed</button>
</div>
<div id="groups"></div>
</div>
<div id="main">
<div id="stagewrap"><div id="stage"></div><div id="marks"></div></div>
<div id="status">drag a layer in the canvas to move it · the open layer is active</div>
<div id="busy">rendering…</div>
</div>
</div>
<script type="module">
import { GROUPS_SCHEMA, COMMON_CONTROLS, DEFAULT_COMPOSITION } from './src/compose/schema.js';
import { COMPOSITION_GROUPS, buildGroupPieces, assemblePieces } from './src/compose/composition.js';
const PW = 760; // preview render size (fast); export uses comp.size
const clone = (o) => JSON.parse(JSON.stringify(o));
let comp = clone(DEFAULT_COMPOSITION);
const cache = {}; // group -> pieces
let active = 'disk'; // currently-open / draggable group
// ---- dotted path get/set within a group object ----
const get = (o, path) => path.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
const set = (o, path, v) => { const ks = path.split('.'); let a = o; for (let i = 0; i < ks.length - 1; i++) a = a[ks[i]]; a[ks[ks.length - 1]] = v; };
// ---- colour helpers (rgb()/#hex <-> hex for the picker) ----
const toHex = (c) => {
if (!c) return '#000000';
if (c[0] === '#') return c;
const m = c.match(/\d+/g); if (!m) return '#000000';
return '#' + m.slice(0, 3).map(n => (+n).toString(16).padStart(2, '0')).join('');
};
const fromHex = (hex, asRgb) => asRgb ? `rgb(${[1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)).join(',')})` : hex;
// ---- render pipeline (per-layer cache) ----
const busy = document.getElementById('busy');
function regen(group) { cache[group] = buildGroupPieces(group, comp, PW); }
function reassemble() {
const all = COMPOSITION_GROUPS.flatMap(g => cache[g] || []);
document.getElementById('stage').innerHTML = assemblePieces(all, PW);
placeMarks();
}
function regenAll() { for (const g of COMPOSITION_GROUPS) regen(g); reassemble(); }
// cheap edit (opacity / transform): no layer regeneration, just re-apply + reassemble
function applyCheap(group) {
const lo = comp[group]?.layerOpacity ?? 1;
(cache[group] || []).forEach(p => { if (!p.rect) p.opacity = (p.baseOpacity ?? p.opacity ?? 1) * lo; });
reassemble();
}
// debounced per-group regen (generation params). transform edits skip regen.
const timers = {};
function scheduleRegen(group) {
busy.style.opacity = 1;
clearTimeout(timers[group]);
timers[group] = setTimeout(() => { regen(group); reassemble(); busy.style.opacity = 0; }, 90);
}
// ---- build the accordion ----
const groupsEl = document.getElementById('groups');
function ctlRow(group, c) {
const cur = c.path.startsWith('transform.') ? get(comp[group], c.path) : get(comp[group], c.path);
const row = document.createElement('div'); row.className = 'ctl';
const cheap = c.path.startsWith('transform.') || c.path === 'layerOpacity';
const onChange = () => { cheap ? applyCheap(group) : scheduleRegen(group); };
if (c.type === 'range') {
row.innerHTML = `<label title="${c.label}">${c.label}</label><input type="range" min="${c.min}" max="${c.max}" step="${c.step}"><span class="val"></span>`;
const inp = row.querySelector('input'), val = row.querySelector('.val');
inp.dataset.g = group; inp.dataset.path = c.path; inp.dataset.dec = c.step < 1 ? 2 : 0;
inp.value = cur; val.textContent = (+cur).toFixed(+inp.dataset.dec);
inp.addEventListener('input', () => { set(comp[group], c.path, +inp.value); val.textContent = (+inp.value).toFixed(+inp.dataset.dec); onChange(); });
} else if (c.type === 'color') {
const asRgb = (typeof cur === 'string' && cur[0] !== '#');
row.innerHTML = `<label>${c.label}</label><input type="color">`;
const inp = row.querySelector('input'); inp.value = toHex(cur);
inp.addEventListener('input', () => { set(comp[group], c.path, fromHex(inp.value, asRgb)); scheduleRegen(group); });
} else if (c.type === 'select') {
row.innerHTML = `<label>${c.label}</label><select>${c.options.map(o => `<option${o === cur ? ' selected' : ''}>${o}</option>`).join('')}</select>`;
row.querySelector('select').addEventListener('change', e => { set(comp[group], c.path, e.target.value); scheduleRegen(group); });
} else if (c.type === 'toggle') {
row.innerHTML = `<label>${c.label}</label><input type="checkbox" ${cur ? 'checked' : ''} style="grid-column:2/4;justify-self:start;width:18px;height:18px">`;
row.querySelector('input').addEventListener('change', e => { set(comp[group], c.path, e.target.checked); scheduleRegen(group); });
} else if (c.type === 'text') {
row.innerHTML = `<label>${c.label}</label><input type="text">`;
const inp = row.querySelector('input'); inp.value = cur ?? '';
inp.addEventListener('input', () => { set(comp[group], c.path, inp.value); scheduleRegen(group); });
}
return row;
}
const COLORS = { fieldSea: '#6fb3a6', fieldGrid: '#9ab0c9', disk: '#d98a4a', bubble: '#d46aa8', fiduciaries: '#cfc7b2', background: '#888' };
function buildSidebar() {
groupsEl.innerHTML = '';
for (const g of GROUPS_SCHEMA) {
const grp = document.createElement('div'); grp.className = 'grp' + (g.id === active ? ' open' : '');
const head = document.createElement('div'); head.className = 'ghead' + (g.id === active ? ' active' : '');
const en = g.enable ? `<input type="checkbox" class="en" ${comp[g.id].enabled !== false ? 'checked' : ''} title="enable layer">` : '';
head.innerHTML = `<span class="ar">▸</span><span class="dot" style="background:${COLORS[g.id]}"></span><span class="nm">${g.label}</span>${en}`;
const body = document.createElement('div'); body.className = 'gbody';
if (g.transform) {
const sub = document.createElement('div'); sub.className = 'sub'; sub.textContent = 'layer'; body.appendChild(sub);
for (const c of COMMON_CONTROLS) body.appendChild(ctlRow(g.id, c));
const sub2 = document.createElement('div'); sub2.className = 'sub'; sub2.textContent = 'generation'; body.appendChild(sub2);
}
for (const c of g.controls) body.appendChild(ctlRow(g.id, c));
head.addEventListener('click', (e) => {
if (e.target.classList.contains('en')) { comp[g.id].enabled = e.target.checked; scheduleRegen(g.id); return; }
const open = grp.classList.toggle('open');
document.querySelectorAll('.ghead').forEach(h => h.classList.remove('active'));
if (open) { active = g.id; head.classList.add('active'); placeMarks(); }
});
grp.appendChild(head); grp.appendChild(body); groupsEl.appendChild(grp);
}
}
// ---- centre markers + drag-to-position ----
const marks = document.getElementById('marks'), stage = document.getElementById('stage');
function transformableGroups() { return GROUPS_SCHEMA.filter(g => g.transform && comp[g.id].enabled !== false); }
function placeMarks() {
marks.innerHTML = '';
for (const g of transformableGroups()) {
const t = comp[g.id].transform; const d = document.createElement('div');
d.className = 'mk' + (g.id === active ? ' on' : '');
d.style.left = (50 + (t.x ?? 0) * 50) + '%'; d.style.top = (50 + (t.y ?? 0) * 50) + '%';
if (g.id === active) d.innerHTML = `<b>${g.label}</b>`;
marks.appendChild(d);
}
}
let dragging = false;
function ptToNorm(ev) {
const r = stage.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * 2 - 1, ((ev.clientY - r.top) / r.height) * 2 - 1];
}
stage.addEventListener('pointerdown', (ev) => {
const [x, y] = ptToNorm(ev);
// pick nearest transformable layer centre
let best = null, bd = 1e9;
for (const g of transformableGroups()) { const t = comp[g.id].transform; const dx = (t.x ?? 0) - x, dy = (t.y ?? 0) - y, dd = dx * dx + dy * dy; if (dd < bd) { bd = dd; best = g.id; } }
if (!best) return;
active = best;
buildSidebar(); // reflect selection (open the picked group)
document.querySelector('.grp.open')?.scrollIntoView({ block: 'nearest' });
dragging = true; stage.setPointerCapture(ev.pointerId); moveActive(ev);
});
stage.addEventListener('pointermove', (ev) => { if (dragging) moveActive(ev); });
stage.addEventListener('pointerup', (ev) => { dragging = false; });
function moveActive(ev) {
const [x, y] = ptToNorm(ev); const t = comp[active].transform;
t.x = Math.max(-1, Math.min(1, +x.toFixed(3))); t.y = Math.max(-1, Math.min(1, +y.toFixed(3)));
// fiduciary arrow follows the bubble unless it has its own target
reassemble();
// sync the open transform sliders
syncSliders(active);
}
function syncSliders(group) {
document.querySelectorAll(`#groups input[type=range][data-g="${group}"]`).forEach(inp => {
const v = get(comp[group], inp.dataset.path); if (v == null) return;
inp.value = v; const val = inp.parentElement.querySelector('.val'); if (val) val.textContent = (+v).toFixed(+inp.dataset.dec);
});
}
// ---- toolbar ----
document.getElementById('exportSvg').addEventListener('click', () => {
const W = comp.size || 1600;
const all = COMPOSITION_GROUPS.flatMap(g => buildGroupPieces(g, comp, W));
download(assemblePieces(all, W), `composition-${comp.seed}.svg`, 'image/svg+xml');
});
document.getElementById('exportCfg').addEventListener('click', () => {
download(`/* composition — exported from composer */\nexport const composition = ${JSON.stringify(comp, null, 2)};\n`, `composition-${comp.seed}.mjs`, 'text/javascript');
});
document.getElementById('loadCfg').addEventListener('change', async (e) => {
const f = e.target.files[0]; if (!f) return;
const text = await f.text();
try {
const url = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }));
const mod = await import(url); comp = clone(mod.composition || mod.default);
} catch (err) { try { comp = JSON.parse(text); } catch { alert('could not load composition'); return; } }
buildSidebar(); regenAll();
});
document.getElementById('reseed').addEventListener('click', () => {
const names = ['MESON', 'LAMBDA', 'HYPERON', 'KAON', 'CASCADE', 'NUCLEON', 'PION', 'SIGMA'];
comp.seed = names[Math.floor(Math.random() * names.length)] + '-' + String(1000 + Math.floor(Math.random() * 8999));
regen('disk'); regen('bubble'); reassemble();
});
function download(text, name, type) {
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type }));
a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// ---- go ----
buildSidebar();
regenAll();
</script>
</body></html>