247 lines
14 KiB
HTML
247 lines
14 KiB
HTML
<!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>
|