Files
bubblechambersimart/bubble_chamber.v2.html
2026-05-20 16:53:23 -04:00

1257 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bubble Chamber — parametric generator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0a;
--panel: #141414;
--panel-2: #1c1c1c;
--line: #2a2a2a;
--ink: #e8e4d8;
--ink-dim: #8a8578;
--ink-mute: #555049;
--accent: #d4a574;
--warn: #c87a4a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg);
color: var(--ink);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
display: grid;
grid-template-columns: 320px 1fr;
height: 100vh;
}
/* ---------- Sidebar ---------- */
aside {
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
padding: 20px 18px;
}
aside::-webkit-scrollbar { width: 6px; }
aside::-webkit-scrollbar-thumb { background: var(--line); }
.brand {
font-family: 'Cormorant Garamond', serif;
font-style: italic;
font-size: 22px;
letter-spacing: 0.02em;
color: var(--ink);
margin-bottom: 2px;
}
.subtitle {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--ink-mute);
margin-bottom: 24px;
}
.group {
border-top: 1px solid var(--line);
padding: 16px 0 8px;
}
.group-title {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.25em;
color: var(--accent);
margin-bottom: 14px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 10px;
}
.row label {
font-size: 11px;
color: var(--ink-dim);
flex: 1;
}
.row .val {
font-size: 11px;
color: var(--ink);
min-width: 50px;
text-align: right;
font-variant-numeric: tabular-nums;
}
input[type=range] {
width: 100%;
-webkit-appearance: none;
appearance: none;
background: transparent;
height: 18px;
}
input[type=range]::-webkit-slider-runnable-track {
height: 1px;
background: var(--line);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 10px;
width: 10px;
background: var(--ink);
border-radius: 0;
margin-top: -4px;
cursor: ew-resize;
transition: background 0.15s;
}
input[type=range]:hover::-webkit-slider-thumb { background: var(--accent); }
input[type=range]::-moz-range-track { height: 1px; background: var(--line); }
input[type=range]::-moz-range-thumb {
height: 10px; width: 10px; background: var(--ink); border: none; border-radius: 0; cursor: ew-resize;
}
.seed-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
align-items: center;
}
.seed-input {
background: var(--panel-2);
border: 1px solid var(--line);
color: var(--ink);
padding: 8px 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
width: 100%;
letter-spacing: 0.05em;
}
.seed-input:focus { outline: none; border-color: var(--accent); }
button {
background: transparent;
border: 1px solid var(--line);
color: var(--ink);
padding: 8px 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.15em;
cursor: pointer;
transition: all 0.15s;
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
button.primary {
background: var(--ink);
color: var(--bg);
border-color: var(--ink);
}
button.primary:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
}
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 8px;
}
.btn-row-full { display: grid; gap: 6px; margin-top: 8px; }
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
cursor: pointer;
}
.checkbox-row input { accent-color: var(--accent); }
.checkbox-row label { font-size: 11px; color: var(--ink-dim); cursor: pointer; }
/* ---------- Stage ---------- */
main {
position: relative;
background: #050505;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.stage-frame {
position: relative;
background: #0a0a0a;
box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--line);
}
#preview {
display: block;
image-rendering: -webkit-optimize-contrast;
}
.stage-meta {
position: absolute;
bottom: 16px;
left: 18px;
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-mute);
font-family: 'JetBrains Mono', monospace;
}
.stage-meta .hash {
color: var(--accent);
letter-spacing: 0.1em;
text-transform: none;
}
.stage-corner {
position: absolute;
top: 16px;
right: 18px;
font-family: 'Cormorant Garamond', serif;
font-style: italic;
font-size: 14px;
color: var(--ink-mute);
text-align: right;
line-height: 1.4;
}
.stage-corner .sm {
font-family: 'JetBrains Mono', monospace;
font-style: normal;
font-size: 9px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-mute);
margin-top: 2px;
}
.toast {
position: absolute;
bottom: 16px;
right: 18px;
background: var(--panel);
border: 1px solid var(--accent);
color: var(--accent);
padding: 8px 14px;
font-size: 10px;
letter-spacing: 0.15em;
text-transform: uppercase;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<aside>
<div class="brand">Bubble Chamber</div>
<div class="subtitle">Parametric Generator · v1</div>
<div class="group" style="border-top:none; padding-top:0;">
<div class="group-title">Seed</div>
<div class="seed-row">
<input class="seed-input" id="seedInput" value="ENTROPY-001" spellcheck="false">
<button id="randomSeed" title="Randomize seed"></button>
</div>
<div class="btn-row-full">
<button id="regen" class="primary">Regenerate</button>
</div>
</div>
<div class="group">
<div class="group-title">Event</div>
<div class="row">
<label>Primary tracks</label>
<span class="val" id="primariesVal">12</span>
</div>
<input type="range" id="primaries" min="3" max="40" value="12" step="1">
<div class="row">
<label>Burst intensity</label>
<span class="val" id="burstVal">0.7</span>
</div>
<input type="range" id="burst" min="0" max="1" value="0.7" step="0.01">
<div class="row">
<label>V-decay vertices</label>
<span class="val" id="vdecayVal">3</span>
</div>
<input type="range" id="vdecay" min="0" max="10" value="3" step="1">
</div>
<div class="group">
<div class="group-title">Field & Trajectory</div>
<div class="row">
<label>Magnetic field |B|</label>
<span class="val" id="bfieldVal">1.0</span>
</div>
<input type="range" id="bfield" min="0.2" max="3" value="1.0" step="0.01">
<div class="row">
<label>Energy-loss rate</label>
<span class="val" id="elossVal">0.4</span>
</div>
<input type="range" id="eloss" min="0" max="1.5" value="0.4" step="0.01">
<div class="row">
<label>Momentum spread</label>
<span class="val" id="pspreadVal">0.5</span>
</div>
<input type="range" id="pspread" min="0" max="1" value="0.5" step="0.01">
</div>
<div class="group">
<div class="group-title">δ-Rays (curly bits)</div>
<div class="row">
<label>Spawn rate</label>
<span class="val" id="deltaRateVal">0.35</span>
</div>
<input type="range" id="deltaRate" min="0" max="1" value="0.35" step="0.01">
<div class="row">
<label>Tightness</label>
<span class="val" id="deltaTightVal">0.6</span>
</div>
<input type="range" id="deltaTight" min="0.1" max="1.5" value="0.6" step="0.01">
</div>
<div class="group">
<div class="group-title">Rendering</div>
<div class="row">
<label>Bubble density</label>
<span class="val" id="densityVal">1.0</span>
</div>
<input type="range" id="density" min="0.2" max="2.5" value="1.0" step="0.01">
<div class="row">
<label>Bubble size</label>
<span class="val" id="sizeVal">1.0</span>
</div>
<input type="range" id="size" min="0.3" max="2.5" value="1.0" step="0.01">
<div class="row">
<label>Film grain</label>
<span class="val" id="grainVal">0.25</span>
</div>
<input type="range" id="grain" min="0" max="1" value="0.25" step="0.01">
<div class="row">
<label>Vignette</label>
<span class="val" id="vignVal">0.4</span>
</div>
<input type="range" id="vign" min="0" max="1" value="0.4" step="0.01">
<div class="checkbox-row">
<input type="checkbox" id="showFiducials" checked>
<label for="showFiducials">Fiducial marks</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="showBoundary" checked>
<label for="showBoundary">Chamber boundary</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="invert" checked>
<label for="invert">Invert · photographic positive</label>
</div>
</div>
<div class="group">
<div class="group-title">Event Layers</div>
<div class="row">
<label>Background events</label>
<span class="val" id="bgEventsVal">4</span>
</div>
<input type="range" id="bgEvents" min="0" max="12" value="4" step="1">
<div class="row">
<label>Background intensity</label>
<span class="val" id="bgIntensityVal">0.35</span>
</div>
<input type="range" id="bgIntensity" min="0" max="1" value="0.35" step="0.01">
<div class="row">
<label>Emulsion artifacts</label>
<span class="val" id="artifactsVal">0.5</span>
</div>
<input type="range" id="artifacts" min="0" max="1" value="0.5" step="0.01">
</div>
<div class="group">
<div class="group-title">Export</div>
<div class="btn-row">
<button id="exportSVG">SVG</button>
<button id="exportPDF">PDF</button>
</div>
<div class="btn-row-full">
<button id="exportPNG">PNG · high-res</button>
</div>
<div style="margin-top:14px; font-size:10px; color:var(--ink-mute); line-height:1.5;">
Print target: 24″ × 24″ @ 300 DPI<br>
Vector exports scale infinitely.
</div>
</div>
</aside>
<main>
<div class="stage-frame">
<canvas id="preview" width="900" height="900"></canvas>
<div class="stage-corner">
Plate <span id="plateNum">001</span><br>
<span class="sm">Exposed · <span id="exposureDate"></span></span>
</div>
<div class="stage-meta">
Seed: <span class="hash" id="hashDisplay"></span>
</div>
</div>
<div class="toast" id="toast">Exported</div>
</main>
<script>
/* ============================================================
BUBBLE CHAMBER — parametric particle track generator
Deterministic from a seed string. Renders to canvas (preview)
and emits SVG / PDF / PNG with identical geometry.
============================================================ */
/* ---------- Seeded PRNG (mulberry32 + cyrb53 hash) ---------- */
function cyrb53(str, seed = 0) {
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(13, '0');
}
function mulberry32(seed) {
let a = seed >>> 0;
return function() {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeRng(seedStr, salt = '') {
const h = cyrb53(seedStr + '::' + salt);
return mulberry32(parseInt(h.slice(0, 8), 16));
}
const gauss = (rng) => {
// Box-Muller
const u = 1 - rng(), v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
/* ---------- Geometry primitives ---------- */
// All geometry computed in a logical unit square [-1,1] x [-1,1].
// Rendering scales to whatever resolution we want.
/* Compute a helical/curved track polyline given starting state.
Returns array of {x, y, beta} sample points.
Physics:
- charged particle in uniform B (out of page) curves with radius r = p/(qB)
- momentum decreases via dE/dx ∝ 1/β² (simplified)
- β tracked as proxy for ionization → bubble density & track thickness */
function integrateTrack(state, params, rng, maxSteps = 1200) {
const pts = [];
let { x, y, theta, p, q } = state;
const m = 1.0; // particle mass (arbitrary units)
const B = params.bfield;
const stepLen = 0.0035;
let traveled = 0;
for (let i = 0; i < maxSteps; i++) {
// β from momentum: β = p / sqrt(p² + m²)
const beta = p / Math.sqrt(p * p + m * m);
pts.push({ x, y, beta, theta });
// step
x += Math.cos(theta) * stepLen;
y += Math.sin(theta) * stepLen;
// curvature: dθ/ds = qB/p (sign of q sets direction)
const dtheta = (q * B / Math.max(p, 0.05)) * stepLen;
theta += dtheta;
// energy loss (Bethe-Bloch-ish): dp/ds ∝ 1/β²
const loss = params.eloss * stepLen / Math.max(beta * beta, 0.05);
p -= loss;
traveled += stepLen;
// termination
if (p < 0.04) break;
if (Math.abs(x) > 1.15 || Math.abs(y) > 1.15) break;
if (traveled > 8) break; // prevent runaway spirals, allows long high-p tracks
}
return pts;
}
/* Log-normal momentum sample: gives the heavy tail real bubble chambers have.
Most tracks moderate p; a few have very high p (long straight lines);
a few have very low p (tight terminal spirals). */
function sampleMomentum(rng, spread) {
// mean ~ 0.7, sigma controlled by `spread`
const sigma = 0.4 + spread * 1.2;
const mu = -0.2;
return Math.exp(mu + sigma * gauss(rng));
}
/* Spawn δ-ray — tuned for visibility. Higher base momentum so it completes
at least one full curl, tighter loss so it spirals all the way in. */
function spawnDeltaRay(parentPt, params, rng) {
const tightness = params.deltaTight;
// p0 chosen so radius ~ (0.02 to 0.08) of chamber — small but visible
const p0 = 0.10 + rng() * 0.20 / tightness;
const perp = parentPt.theta + (rng() < 0.5 ? Math.PI / 2 : -Math.PI / 2);
const theta = perp + gauss(rng) * 0.5;
const q = -1;
// boosted B and lower loss → tight curls that spiral in cleanly
return integrateTrack(
{ x: parentPt.x, y: parentPt.y, theta, p: p0, q },
{ ...params, bfield: params.bfield * 2.5, eloss: params.eloss * 0.6 },
rng,
900
);
}
/* V-decay: a neutral particle (invisible) travels then decays into
two oppositely-charged daughters. */
function spawnVDecay(originPt, params, rng) {
const dist = 0.15 + rng() * 0.35;
const ghostTheta = rng() * Math.PI * 2;
const vx = originPt.x + Math.cos(ghostTheta) * dist;
const vy = originPt.y + Math.sin(ghostTheta) * dist;
if (Math.abs(vx) > 0.85 || Math.abs(vy) > 0.85) return [];
const opening = 0.3 + rng() * 0.7;
const p1 = 0.4 + rng() * 0.8;
const p2 = 0.4 + rng() * 0.8;
const t1 = integrateTrack(
{ x: vx, y: vy, theta: ghostTheta - opening / 2, p: p1, q: +1 }, params, rng
);
const t2 = integrateTrack(
{ x: vx, y: vy, theta: ghostTheta + opening / 2, p: p2, q: -1 }, params, rng
);
return [t1, t2];
}
/* ---------- Event generation ---------- */
/* Generate a single event at a given vertex with a given intensity.
Intensity = 1.0 for foreground events, lower for background/historical.
Returns array of tracks (each carries its own intensity in `weight`). */
function generateOneEvent(params, vertex, intensity, eventSalt) {
const rng = makeRng(params.seed, 'event:' + eventSalt);
const tracks = [];
const N = Math.max(2, Math.round(params.primaries * (0.4 + intensity * 0.6)));
const burstConcentration = 0.3 + params.burst * 0.7;
for (let i = 0; i < N; i++) {
const baseAngle = (i / N) * Math.PI * 2;
const angle = baseAngle + gauss(rng) * 0.4 * (1 - burstConcentration);
const p = sampleMomentum(rng, params.pspread);
const q = rng() < 0.5 ? +1 : -1;
const pts = integrateTrack(
{ x: vertex.x, y: vertex.y, theta: angle, p, q },
params, makeRng(params.seed, eventSalt + ':primary' + i)
);
tracks.push({ pts, kind: 'primary', weight: intensity });
// δ-rays — only on foreground/near-foreground events
if (intensity > 0.6) {
const deltaRng = makeRng(params.seed, eventSalt + ':delta' + i);
// sample roughly every other step
for (let j = 5; j < pts.length; j += 2) {
if (deltaRng() < params.deltaRate * 0.08) {
const dpts = spawnDeltaRay(pts[j], params, deltaRng);
if (dpts.length > 8) tracks.push({ pts: dpts, kind: 'delta', weight: intensity * 0.85 });
}
}
}
}
// V-decays only for foreground
if (intensity > 0.8) {
for (let i = 0; i < params.vdecay; i++) {
const daughters = spawnVDecay(vertex, params, makeRng(params.seed, eventSalt + ':vdecay' + i));
daughters.forEach(d => {
if (d.length > 10) tracks.push({ pts: d, kind: 'vdecay', weight: intensity * 0.95 });
});
}
}
return tracks;
}
/* Top-level scene: one foreground event + N background events at lower intensity.
Backgrounds are scattered through the chamber volume and represent the
"history" of the chamber — cosmic rays, prior pulses, etc. */
function generateScene(params) {
const rng = makeRng(params.seed, 'scene');
const allTracks = [];
// Foreground event near (slightly off) center
const fgVertex = { x: (rng() - 0.5) * 0.25, y: (rng() - 0.5) * 0.25 };
allTracks.push(...generateOneEvent(params, fgVertex, 1.0, 'fg'));
// Background events — random vertices, varied intensity
const nBg = params.bgEvents || 0;
for (let i = 0; i < nBg; i++) {
const bgRng = makeRng(params.seed, 'bg' + i);
const vx = (bgRng() - 0.5) * 1.7;
const vy = (bgRng() - 0.5) * 1.7;
// vary intensity within the user's bgIntensity ceiling
const intensity = params.bgIntensity * (0.5 + bgRng() * 0.5);
allTracks.push(...generateOneEvent(
{ ...params, primaries: Math.round(params.primaries * 0.6), vdecay: 0 },
{ x: vx, y: vy }, intensity, 'bg' + i
));
}
// Emulsion artifacts — long straight scratches, dust specks
const artifacts = generateArtifacts(params);
return { tracks: allTracks, vertex: fgVertex, artifacts };
}
/* Emulsion artifacts: scratches, dust specks, water spots.
These are not particle tracks — they're imperfections in the photographic
plate and chamber window that real archival images always have. */
function generateArtifacts(params) {
const out = { scratches: [], specks: [], rings: [] };
if (!params.artifacts) return out;
const rng = makeRng(params.seed, 'artifacts');
const A = params.artifacts;
// Scratches: long, very thin, straight lines at low angles
const nScratch = Math.floor(A * 6 + rng() * A * 4);
for (let i = 0; i < nScratch; i++) {
const cx = (rng() - 0.5) * 2;
const cy = (rng() - 0.5) * 2;
const ang = rng() * Math.PI * 2;
const len = 0.3 + rng() * 1.5;
const opacity = 0.15 + rng() * 0.3;
out.scratches.push({
x1: cx - Math.cos(ang) * len / 2,
y1: cy - Math.sin(ang) * len / 2,
x2: cx + Math.cos(ang) * len / 2,
y2: cy + Math.sin(ang) * len / 2,
opacity: opacity * A,
width: 0.3 + rng() * 0.4
});
}
// Dust specks: small irregular dark spots
const nSpecks = Math.floor(A * 80 + rng() * A * 60);
for (let i = 0; i < nSpecks; i++) {
out.specks.push({
x: (rng() - 0.5) * 2,
y: (rng() - 0.5) * 2,
r: 0.0008 + rng() * 0.003,
opacity: 0.3 + rng() * 0.5 * A
});
}
// Water/chemical rings: occasional faint circles
const nRings = Math.floor(A * 3);
for (let i = 0; i < nRings; i++) {
out.rings.push({
x: (rng() - 0.5) * 1.6,
y: (rng() - 0.5) * 1.6,
r: 0.05 + rng() * 0.15,
opacity: 0.08 + rng() * 0.12 * A,
width: 0.5 + rng() * 1.5
});
}
return out;
}
/* ---------- Bubble sampling ---------- */
/* Convert a polyline into discrete bubbles along it.
Density modulated by β (slow particles ionize more → denser bubbles). */
function sampleBubbles(track, params, rng) {
const bubbles = [];
const pts = track.pts;
if (pts.length < 2) return bubbles;
// Walk the polyline
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i];
const dx = b.x - a.x, dy = b.y - a.y;
const segLen = Math.hypot(dx, dy);
// bubble density ∝ 1/β² × user density × track weight
const beta = Math.max(a.beta, 0.15);
const lambda = params.density * track.weight * (1 / (beta * beta)) * 380;
const expected = lambda * segLen;
// Poisson approximation: use uniform sampling for speed
const nb = Math.floor(expected) + (rng() < (expected - Math.floor(expected)) ? 1 : 0);
for (let k = 0; k < nb; k++) {
const t = rng();
const px = a.x + dx * t;
const py = a.y + dy * t;
// tiny perpendicular jitter
const nx = -dy / segLen, ny = dx / segLen;
const j = gauss(rng) * 0.0015;
// bubble radius modulated by β and user size
const baseR = (0.0010 + (1 - beta) * 0.0014) * params.size;
const r = baseR * (0.7 + rng() * 0.5);
bubbles.push({ x: px + nx * j, y: py + ny * j, r });
}
}
return bubbles;
}
/* ---------- Canvas renderer (preview) ---------- */
function renderCanvas(ctx, w, h, scene, params) {
// Palette: inverted = light cream background + dark ink (photographic positive)
// normal = dark + light (the original AI-default look)
const inv = params.invert;
const bgInner = inv ? '#d8d2c0' : '#1c1a16';
const bgOuter = inv ? '#b8b09c' : '#080706';
const bgFlat = inv ? '#cfc8b4' : '#0e0d0b';
const ink = inv ? '#1a1815' : '#e8e4d7';
const inkRGB = inv ? '26,24,21' : '232,228,215';
const boundaryStroke = inv ? 'rgba(40,36,30,0.5)' : 'rgba(200,195,180,0.4)';
const fidStroke = inv ? 'rgba(40,36,30,0.6)' : 'rgba(232,228,215,0.55)';
const spokeStroke = inv ? 'rgba(40,36,30,0.22)' : 'rgba(200,195,180,0.18)';
// background
ctx.fillStyle = bgFlat;
ctx.fillRect(0, 0, w, h);
// Subtle radial gradient — gas glow
const grad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w*0.7);
grad.addColorStop(0, bgInner);
grad.addColorStop(1, bgOuter);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
const margin = 0.04;
const scale = (w / 2) * (1 - margin);
const cx = w / 2, cy = h / 2;
const tx = (x) => cx + x * scale;
const ty = (y) => cy + y * scale;
// Chamber boundary
if (params.showBoundary) {
ctx.strokeStyle = boundaryStroke;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy + h * 0.35, w * 0.45, Math.PI * 0.15, Math.PI - Math.PI * 0.15, false);
ctx.stroke();
ctx.strokeStyle = spokeStroke;
ctx.lineWidth = 0.5;
const spokeRng = makeRng(params.seed, 'spokes');
for (let i = 0; i < 60; i++) {
const a = Math.PI + Math.PI * (0.15 + 0.7 * i / 59);
const r1 = w * 0.42;
const r2 = w * 0.46 + spokeRng() * 6;
const ox = cx, oy = cy + h * 0.35;
ctx.beginPath();
ctx.moveTo(ox + Math.cos(a) * r1, oy + Math.sin(a) * r1);
ctx.lineTo(ox + Math.cos(a) * r2, oy + Math.sin(a) * r2);
ctx.stroke();
}
}
// Artifacts layer FIRST (so tracks draw over them) — water rings + scratches under,
// dust specks over.
if (scene.artifacts) {
// Water rings (very faint, broad)
for (const r of scene.artifacts.rings) {
ctx.strokeStyle = `rgba(${inkRGB},${r.opacity})`;
ctx.lineWidth = r.width;
ctx.beginPath();
ctx.arc(tx(r.x), ty(r.y), r.r * scale, 0, Math.PI * 2);
ctx.stroke();
}
// Scratches — long thin lines
for (const s of scene.artifacts.scratches) {
ctx.strokeStyle = `rgba(${inkRGB},${s.opacity})`;
ctx.lineWidth = s.width;
ctx.beginPath();
ctx.moveTo(tx(s.x1), ty(s.y1));
ctx.lineTo(tx(s.x2), ty(s.y2));
ctx.stroke();
}
}
// Bubbles — opacity & size scale with track weight (background events fainter)
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const bubs = sampleBubbles(track, params, bubbleRng);
const alpha = 0.55 + track.weight * 0.4; // foreground darker, background lighter
ctx.fillStyle = `rgba(${inkRGB},${alpha})`;
for (const b of bubs) {
const r = b.r * scale;
ctx.beginPath();
ctx.arc(tx(b.x), ty(b.y), Math.max(r, 0.35), 0, Math.PI * 2);
ctx.fill();
}
}
// Dust specks — on top of everything
if (scene.artifacts) {
for (const sp of scene.artifacts.specks) {
ctx.fillStyle = `rgba(${inkRGB},${sp.opacity})`;
ctx.beginPath();
ctx.arc(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
// Fiducial marks
if (params.showFiducials) {
ctx.strokeStyle = fidStroke;
ctx.lineWidth = 1;
const fids = [
[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85],
[0, -0.85], [-0.85, 0], [0.85, 0]
];
for (const [fx, fy] of fids) {
const px = tx(fx), py = ty(fy);
const s = 6;
ctx.beginPath();
ctx.moveTo(px - s, py); ctx.lineTo(px + s, py);
ctx.moveTo(px, py - s); ctx.lineTo(px, py + s);
ctx.stroke();
}
}
// Vignette
if (params.vign > 0) {
const vignColor = inv ? `rgba(60,50,38,${params.vign * 0.55})` : `rgba(0,0,0,${params.vign * 0.85})`;
const vg = ctx.createRadialGradient(w/2, h/2, w*0.25, w/2, h/2, w*0.72);
vg.addColorStop(0, inv ? 'rgba(60,50,38,0)' : 'rgba(0,0,0,0)');
vg.addColorStop(1, vignColor);
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w, h);
}
// Film grain
if (params.grain > 0) {
const img = ctx.getImageData(0, 0, w, h);
const d = img.data;
const amount = params.grain * 35;
const grng = makeRng(params.seed, 'grain');
for (let i = 0; i < d.length; i += 4) {
const n = (grng() - 0.5) * amount;
d[i] = Math.max(0, Math.min(255, d[i] + n));
d[i+1] = Math.max(0, Math.min(255, d[i+1] + n));
d[i+2] = Math.max(0, Math.min(255, d[i+2] + n));
}
ctx.putImageData(img, 0, 0);
}
}
/* ---------- SVG renderer ---------- */
function renderSVG(scene, params, sizePx = 2400) {
const w = sizePx, h = sizePx;
const margin = 0.04;
const scale = (w / 2) * (1 - margin);
const cx = w / 2, cy = h / 2;
const tx = (x) => (cx + x * scale).toFixed(2);
const ty = (y) => (cy + y * scale).toFixed(2);
const inv = params.invert;
const bgInner = inv ? '#d8d2c0' : '#1c1a16';
const bgOuter = inv ? '#b8b09c' : '#080706';
const bgFlat = inv ? '#cfc8b4' : '#0e0d0b';
const ink = inv ? '#1a1815' : '#e8e4d7';
const boundaryStroke = inv ? '#28241e' : '#c8c3b4';
const boundaryOpacity = inv ? 0.5 : 0.4;
const spokeOpacity = inv ? 0.22 : 0.18;
const fidOpacity = inv ? 0.6 : 0.55;
const vignColor = inv ? '#3c3226' : '#000000';
const vignOpacity = inv ? params.vign * 0.55 : params.vign * 0.85;
let svg = `<?xml version="1.0" encoding="UTF-8"?>\n`;
svg += `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">\n`;
svg += `<metadata>Bubble Chamber Generator · seed=${params.seed} · hash=${cyrb53(params.seed)}</metadata>\n`;
svg += `<defs>
<radialGradient id="bg" cx="50%" cy="50%" r="70%">
<stop offset="0%" stop-color="${bgInner}"/>
<stop offset="100%" stop-color="${bgOuter}"/>
</radialGradient>
<radialGradient id="vign" cx="50%" cy="50%" r="72%">
<stop offset="35%" stop-color="${vignColor}" stop-opacity="0"/>
<stop offset="100%" stop-color="${vignColor}" stop-opacity="${vignOpacity}"/>
</radialGradient>
</defs>\n`;
svg += `<rect width="${w}" height="${h}" fill="${bgFlat}"/>\n`;
svg += `<rect width="${w}" height="${h}" fill="url(#bg)"/>\n`;
// Boundary
if (params.showBoundary) {
const bcx = cx, bcy = cy + h * 0.35, br = w * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
const x1 = bcx + Math.cos(Math.PI + a1) * br;
const y1 = bcy + Math.sin(Math.PI + a1) * br;
const x2 = bcx + Math.cos(Math.PI + a2) * br;
const y2 = bcy + Math.sin(Math.PI + a2) * br;
svg += `<path d="M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${br} ${br} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}" `;
svg += `fill="none" stroke="${boundaryStroke}" stroke-opacity="${boundaryOpacity}" stroke-width="2"/>\n`;
const spokeRng = makeRng(params.seed, 'spokes');
for (let i = 0; i < 60; i++) {
const a = Math.PI + Math.PI * (0.15 + 0.7 * i / 59);
const r1 = w * 0.42;
const r2 = w * 0.46 + spokeRng() * 8;
const sx1 = bcx + Math.cos(a) * r1, sy1 = bcy + Math.sin(a) * r1;
const sx2 = bcx + Math.cos(a) * r2, sy2 = bcy + Math.sin(a) * r2;
svg += `<line x1="${sx1.toFixed(1)}" y1="${sy1.toFixed(1)}" x2="${sx2.toFixed(1)}" y2="${sy2.toFixed(1)}" stroke="${boundaryStroke}" stroke-opacity="${spokeOpacity}" stroke-width="0.7"/>\n`;
}
}
// Artifacts: rings + scratches under, dust over
if (scene.artifacts) {
svg += `<g fill="none" stroke="${ink}">\n`;
for (const r of scene.artifacts.rings) {
svg += `<circle cx="${tx(r.x)}" cy="${ty(r.y)}" r="${(r.r * scale).toFixed(1)}" stroke-opacity="${r.opacity.toFixed(3)}" stroke-width="${r.width}"/>\n`;
}
for (const s of scene.artifacts.scratches) {
svg += `<line x1="${tx(s.x1)}" y1="${ty(s.y1)}" x2="${tx(s.x2)}" y2="${ty(s.y2)}" stroke-opacity="${s.opacity.toFixed(3)}" stroke-width="${s.width}"/>\n`;
}
svg += `</g>\n`;
}
// Bubbles — group by weight bucket for varying opacity
const buckets = new Map();
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const bucketKey = Math.round((0.55 + track.weight * 0.4) * 20) / 20;
if (!buckets.has(bucketKey)) buckets.set(bucketKey, []);
const bubs = sampleBubbles(track, params, bubbleRng);
buckets.get(bucketKey).push(...bubs);
}
for (const [alpha, bubs] of buckets) {
svg += `<g fill="${ink}" fill-opacity="${alpha}">`;
for (const b of bubs) {
const r = Math.max(b.r * scale, 0.4);
svg += `<circle cx="${tx(b.x)}" cy="${ty(b.y)}" r="${r.toFixed(2)}"/>`;
}
svg += `</g>\n`;
}
// Dust specks
if (scene.artifacts) {
svg += `<g fill="${ink}">\n`;
for (const sp of scene.artifacts.specks) {
svg += `<circle cx="${tx(sp.x)}" cy="${ty(sp.y)}" r="${Math.max(sp.r * scale, 0.5).toFixed(2)}" fill-opacity="${sp.opacity.toFixed(3)}"/>`;
}
svg += `</g>\n`;
}
// Fiducials
if (params.showFiducials) {
svg += `<g stroke="${ink}" stroke-opacity="${fidOpacity}" stroke-width="1.2">\n`;
const fids = [[-0.85,-0.85],[0.85,-0.85],[-0.85,0.85],[0.85,0.85],[0,-0.85],[-0.85,0],[0.85,0]];
for (const [fx, fy] of fids) {
const px = +tx(fx), py = +ty(fy), s = 9;
svg += `<line x1="${px-s}" y1="${py}" x2="${px+s}" y2="${py}"/>`;
svg += `<line x1="${px}" y1="${py-s}" x2="${px}" y2="${py+s}"/>`;
}
svg += `</g>\n`;
}
if (params.vign > 0) {
svg += `<rect width="${w}" height="${h}" fill="url(#vign)"/>\n`;
}
svg += `</svg>\n`;
return svg;
}
/* ---------- App state & wiring ---------- */
const canvas = document.getElementById('preview');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const sliders = {
primaries: { el: 'primaries', valEl: 'primariesVal', fmt: v => v },
burst: { el: 'burst', valEl: 'burstVal', fmt: v => v.toFixed(2) },
vdecay: { el: 'vdecay', valEl: 'vdecayVal', fmt: v => v },
bfield: { el: 'bfield', valEl: 'bfieldVal', fmt: v => v.toFixed(2) },
eloss: { el: 'eloss', valEl: 'elossVal', fmt: v => v.toFixed(2) },
pspread: { el: 'pspread', valEl: 'pspreadVal', fmt: v => v.toFixed(2) },
deltaRate: { el: 'deltaRate', valEl: 'deltaRateVal', fmt: v => v.toFixed(2) },
deltaTight: { el: 'deltaTight', valEl: 'deltaTightVal', fmt: v => v.toFixed(2) },
density: { el: 'density', valEl: 'densityVal', fmt: v => v.toFixed(2) },
size: { el: 'size', valEl: 'sizeVal', fmt: v => v.toFixed(2) },
grain: { el: 'grain', valEl: 'grainVal', fmt: v => v.toFixed(2) },
vign: { el: 'vign', valEl: 'vignVal', fmt: v => v.toFixed(2) },
bgEvents: { el: 'bgEvents', valEl: 'bgEventsVal', fmt: v => v },
bgIntensity: { el: 'bgIntensity', valEl: 'bgIntensityVal', fmt: v => v.toFixed(2) },
artifacts: { el: 'artifacts', valEl: 'artifactsVal', fmt: v => v.toFixed(2) },
};
function readParams() {
const p = { seed: document.getElementById('seedInput').value || 'DEFAULT' };
for (const [key, cfg] of Object.entries(sliders)) {
p[key] = parseFloat(document.getElementById(cfg.el).value);
}
p.showFiducials = document.getElementById('showFiducials').checked;
p.showBoundary = document.getElementById('showBoundary').checked;
p.invert = document.getElementById('invert').checked;
return p;
}
function updateValueLabels() {
for (const [key, cfg] of Object.entries(sliders)) {
const v = parseFloat(document.getElementById(cfg.el).value);
document.getElementById(cfg.valEl).textContent = cfg.fmt(v);
}
}
let currentScene = null;
let currentParams = null;
let renderTimer = null;
function regenerateAndRender() {
currentParams = readParams();
currentScene = generateScene(currentParams);
renderCanvas(ctx, canvas.width, canvas.height, currentScene, currentParams);
document.getElementById('hashDisplay').textContent = cyrb53(currentParams.seed).toUpperCase();
const plate = (parseInt(cyrb53(currentParams.seed).slice(-3), 16) % 999).toString().padStart(3, '0');
document.getElementById('plateNum').textContent = plate;
}
function rerenderOnly() {
// for slider changes that affect rendering but not the scene (grain, vignette, etc.)
if (!currentScene) return;
currentParams = readParams();
renderCanvas(ctx, canvas.width, canvas.height, currentScene, currentParams);
}
function scheduleRerender(needsRegen) {
clearTimeout(renderTimer);
renderTimer = setTimeout(() => {
if (needsRegen) regenerateAndRender();
else rerenderOnly();
}, 30);
}
// Slider wiring
const sceneAffecting = new Set(['primaries','burst','vdecay','bfield','eloss','pspread','deltaRate','deltaTight','bgEvents','bgIntensity']);
for (const [key, cfg] of Object.entries(sliders)) {
const el = document.getElementById(cfg.el);
el.addEventListener('input', () => {
updateValueLabels();
scheduleRerender(sceneAffecting.has(key));
});
}
document.getElementById('seedInput').addEventListener('change', regenerateAndRender);
document.getElementById('showFiducials').addEventListener('change', rerenderOnly);
document.getElementById('showBoundary').addEventListener('change', rerenderOnly);
document.getElementById('invert').addEventListener('change', rerenderOnly);
document.getElementById('regen').addEventListener('click', regenerateAndRender);
document.getElementById('randomSeed').addEventListener('click', () => {
const words = ['MUON','KAON','PION','LAMBDA','SIGMA','XI','OMEGA','TAU','GLUON','QUARK','HADRON','BARYON','LEPTON','NEUTRINO','BOSON'];
const w = words[Math.floor(Math.random() * words.length)];
const n = Math.floor(Math.random() * 9000 + 1000);
document.getElementById('seedInput').value = `${w}-${n}`;
regenerateAndRender();
});
/* ---------- Export handlers ---------- */
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);
}
document.getElementById('exportSVG').addEventListener('click', () => {
if (!currentScene) regenerateAndRender();
const svg = renderSVG(currentScene, currentParams, 4800);
download(new Blob([svg], { type: 'image/svg+xml' }),
`bubble-chamber-${currentParams.seed}.svg`);
showToast('SVG exported');
});
document.getElementById('exportPDF').addEventListener('click', () => {
// PDF: emit a single-page PDF that embeds the SVG geometry as native PDF ops.
// Simpler & smaller than raster. We just translate circles + paths.
if (!currentScene) regenerateAndRender();
const pdf = buildPDF(currentScene, currentParams);
download(new Blob([pdf], { type: 'application/pdf' }),
`bubble-chamber-${currentParams.seed}.pdf`);
showToast('PDF exported');
});
document.getElementById('exportPNG').addEventListener('click', () => {
if (!currentScene) regenerateAndRender();
// Render at 4× resolution off-screen for sharper PNG
const off = document.createElement('canvas');
off.width = 3600; off.height = 3600;
const octx = off.getContext('2d');
renderCanvas(octx, off.width, off.height, currentScene, currentParams);
off.toBlob(b => {
download(b, `bubble-chamber-${currentParams.seed}.png`);
showToast('PNG exported');
}, 'image/png');
});
/* ---------- PDF writer (minimal, single-page vector) ---------- */
function buildPDF(scene, params) {
const pageSize = 1728;
const margin = 0.04;
const scale = (pageSize / 2) * (1 - margin);
const cx = pageSize / 2, cy = pageSize / 2;
const tx = (x) => (cx + x * scale).toFixed(2);
const ty = (y) => (cy - y * scale).toFixed(2); // flipped
const inv = params.invert;
// PDF colors (0..1 RGB)
const bgColor = inv ? '0.812 0.784 0.706' : '0.055 0.051 0.043'; // #cfc8b4 / #0e0d0b
const inkColor = inv ? '0.102 0.094 0.082' : '0.910 0.894 0.843'; // #1a1815 / #e8e4d7
const inkStroke = inv ? '0.102 0.094 0.082' : '0.910 0.894 0.843';
const boundaryStroke = inv ? '0.157 0.141 0.118' : '0.784 0.764 0.706';
let content = '';
content += `${bgColor} rg\n`;
content += `0 0 ${pageSize} ${pageSize} re f\n`;
// boundary
if (params.showBoundary) {
content += `q\n${boundaryStroke} RG\n0.4 w\n`;
const bcx = cx, bcy = cy - pageSize * 0.35;
const br = pageSize * 0.45;
const a1 = Math.PI * 0.15, a2 = Math.PI - Math.PI * 0.15;
const steps = 80;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const a = Math.PI + a1 + (a2 - a1) * t;
const px = bcx + Math.cos(a) * br;
const py = bcy - Math.sin(a) * br;
content += `${px.toFixed(1)} ${py.toFixed(1)} ${i === 0 ? 'm' : 'l'}\n`;
}
content += `S\nQ\n`;
}
// artifacts: rings + scratches
if (scene.artifacts) {
content += `q\n${inkStroke} RG\n`;
for (const r of scene.artifacts.rings) {
content += `${r.width} w\n`;
const px = parseFloat(tx(r.x)), py = parseFloat(ty(r.y));
const rr = r.r * scale;
// approximate circle stroke with bezier
const k = 0.5522847498 * rr;
content += `${(px - rr).toFixed(2)} ${py.toFixed(2)} m\n`;
content += `${(px - rr).toFixed(2)} ${(py + k).toFixed(2)} ${(px - k).toFixed(2)} ${(py + rr).toFixed(2)} ${px.toFixed(2)} ${(py + rr).toFixed(2)} c\n`;
content += `${(px + k).toFixed(2)} ${(py + rr).toFixed(2)} ${(px + rr).toFixed(2)} ${(py + k).toFixed(2)} ${(px + rr).toFixed(2)} ${py.toFixed(2)} c\n`;
content += `${(px + rr).toFixed(2)} ${(py - k).toFixed(2)} ${(px + k).toFixed(2)} ${(py - rr).toFixed(2)} ${px.toFixed(2)} ${(py - rr).toFixed(2)} c\n`;
content += `${(px - k).toFixed(2)} ${(py - rr).toFixed(2)} ${(px - rr).toFixed(2)} ${(py - k).toFixed(2)} ${(px - rr).toFixed(2)} ${py.toFixed(2)} c\n`;
content += `S\n`;
}
for (const s of scene.artifacts.scratches) {
content += `${s.width} w\n`;
content += `${tx(s.x1)} ${ty(s.y1)} m ${tx(s.x2)} ${ty(s.y2)} l S\n`;
}
content += `Q\n`;
}
// bubbles
content += `q\n${inkColor} rg\n`;
const k = 0.5522847498;
const bubbleRng = makeRng(params.seed, 'bubbles');
for (const track of scene.tracks) {
const bubs = sampleBubbles(track, params, bubbleRng);
for (const b of bubs) {
const r = Math.max(b.r * scale, 0.5);
const px = parseFloat(tx(b.x)), py = parseFloat(ty(b.y));
const rk = r * k;
content += `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n`;
content += `${(px - r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px - rk).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n`;
content += `${(px + rk).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n`;
content += `${(px + r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px + rk).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n`;
content += `${(px - rk).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
content += `f\n`;
}
}
content += `Q\n`;
// dust specks
if (scene.artifacts) {
content += `q\n${inkColor} rg\n`;
for (const sp of scene.artifacts.specks) {
const r = Math.max(sp.r * scale, 0.5);
const px = parseFloat(tx(sp.x)), py = parseFloat(ty(sp.y));
const rk = r * k;
content += `${(px - r).toFixed(2)} ${py.toFixed(2)} m\n`;
content += `${(px - r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px - rk).toFixed(2)} ${(py + r).toFixed(2)} ${px.toFixed(2)} ${(py + r).toFixed(2)} c\n`;
content += `${(px + rk).toFixed(2)} ${(py + r).toFixed(2)} ${(px + r).toFixed(2)} ${(py + rk).toFixed(2)} ${(px + r).toFixed(2)} ${py.toFixed(2)} c\n`;
content += `${(px + r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px + rk).toFixed(2)} ${(py - r).toFixed(2)} ${px.toFixed(2)} ${(py - r).toFixed(2)} c\n`;
content += `${(px - rk).toFixed(2)} ${(py - r).toFixed(2)} ${(px - r).toFixed(2)} ${(py - rk).toFixed(2)} ${(px - r).toFixed(2)} ${py.toFixed(2)} c\n`;
content += `f\n`;
}
content += `Q\n`;
}
if (params.showFiducials) {
content += `q\n${inkStroke} RG\n1.2 w\n`;
const fids = [[-0.85,-0.85],[0.85,-0.85],[-0.85,0.85],[0.85,0.85],[0,-0.85],[-0.85,0],[0.85,0]];
for (const [fx, fy] of fids) {
const px = parseFloat(tx(fx)), py = parseFloat(ty(fy)), s = 8;
content += `${px - s} ${py} m ${px + s} ${py} l S\n`;
content += `${px} ${py - s} m ${px} ${py + s} l S\n`;
}
content += `Q\n`;
}
// Assemble PDF
const enc = new TextEncoder();
const objects = [];
const add = (s) => { objects.push(enc.encode(s)); };
const contentBytes = enc.encode(content);
const header = `%PDF-1.4\n%\xC3\xA0\xC3\xA1\xC3\xA2\xC3\xA3\n`;
let body = header;
const offsets = [];
const addObj = (s) => {
offsets.push(body.length);
body += s;
};
addObj(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n`);
addObj(`2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n`);
addObj(`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageSize} ${pageSize}] /Contents 4 0 R /Resources << >> >>\nendobj\n`);
addObj(`4 0 obj\n<< /Length ${contentBytes.length} >>\nstream\n${content}\nendstream\nendobj\n`);
const xrefOffset = body.length;
body += `xref\n0 5\n0000000000 65535 f \n`;
for (const o of offsets) {
body += String(o).padStart(10, '0') + ' 00000 n \n';
}
body += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return new Uint8Array(enc.encode(body));
}
/* ---------- Init ---------- */
function init() {
// exposure date — looks like a real archival plate
const d = new Date();
d.setFullYear(1973 + Math.floor(Math.random() * 8));
d.setMonth(Math.floor(Math.random() * 12));
d.setDate(1 + Math.floor(Math.random() * 28));
document.getElementById('exposureDate').textContent =
d.toISOString().slice(0, 10).replace(/-/g, '.');
updateValueLabels();
regenerateAndRender();
}
init();
</script>
</body>
</html>