fuzzyworld

basin / commit

Add loading overlay with progress bar for large board generation

b9acd697966eaae38cccc1b44bfc7e6ea84d2caf · Pavel Safronov · 2026-07-01 15:24:00 -0700 · parents 03c2cc1296c0

Claude Code usage: this commit ~31.2K out / ~1.8M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 1 tally 9.3K in / 31.2K out / 1.8M cached · usage by commit and session

Add loading overlay with progress bar for large board generation

Move puzzle generation to a Web Worker so the UI stays responsive
during expensive builds (100×100 takes ~2s). Show a modal overlay
with a progress bar that fills at roughly constant visual speed:
sig-loop and uniqueness-attempt phases are weighted by a cost model
(measured sig time + bigint-slowdown factor) so neither phase
dominates the bar.

Also raise SIZE_MAX to 100 and wire the slider max dynamically from
the constant.

Usage: 9.3K in / 31.2K out / 1.8M cached (session tally)
rtk:   2.6M saved, 80.4% over 1391 cmds (global/lifetime)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 index.html                 |  33 ++++++++++++---
 readme.md                  |   5 +++
 src/game.ts                |  71 +++++++++++++++++++++++--------
 src/main.ts                |  64 ++++++++++++++++++++++++++--
 src/model/puzzle.ts        |  42 ++++++++++++++++--
 src/model/puzzle.worker.ts |  21 +++++++++
 src/style.css              | 103 +++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 308 insertions(+), 31 deletions(-)

diff --git a/index.html b/index.html
index 2a21fd6..4e1cae0 100644
--- a/index.html
+++ b/index.html
@@ -15,26 +15,41 @@
         </header>
         <section id="config" class="config">
           <label class="ctl">
-            Size <span id="cfg-size-val" class="val"></span>
+            <span class="ctl-head">
+              <span>Size <span id="cfg-size-val" class="val"></span></span>
+              <button type="button" class="lock" data-lock="size" aria-pressed="false">🔓</button>
+            </span>
             <input type="range" id="cfg-size" min="5" max="20" step="1" />
           </label>
           <label class="ctl">
-            Springs
+            <span class="ctl-head">
+              <span>Springs</span>
+              <button type="button" class="lock" data-lock="springCount" aria-pressed="false">🔓</button>
+            </span>
             <input type="number" id="cfg-springs" min="1" max="6" step="1" />
           </label>
           <fieldset class="ctl edges">
-            <legend>Walls</legend>
+            <legend>
+              <span>Walls</span>
+              <button type="button" class="lock" data-lock="edges" aria-pressed="false">🔓</button>
+            </legend>
             <label><input type="checkbox" id="cfg-wall-n" /> N</label>
             <label><input type="checkbox" id="cfg-wall-e" /> E</label>
             <label><input type="checkbox" id="cfg-wall-s" /> S</label>
             <label><input type="checkbox" id="cfg-wall-w" /> W</label>
           </fieldset>
           <label class="ctl" title="Simulated erosion: how weathered the terrain is before you arrive. 0 is a pristine dome; higher values carve grooves and change the edge pattern.">
-            Aging <span id="cfg-erosion-val" class="val"></span>
+            <span class="ctl-head">
+              <span>Aging <span id="cfg-erosion-val" class="val"></span></span>
+              <button type="button" class="lock" data-lock="erosion" aria-pressed="false">🔓</button>
+            </span>
             <input type="range" id="cfg-erosion" min="0" max="15" step="1" />
           </label>
           <label class="ctl">
-            Terrain
+            <span class="ctl-head">
+              <span>Terrain</span>
+              <button type="button" class="lock" data-lock="terrainSeed" aria-pressed="false">🔓</button>
+            </span>
             <span class="seedrow">
               <input
                 type="text"
@@ -68,6 +83,14 @@
         <canvas id="board"></canvas>
       </main>
     </div>
+    <div id="loading" class="loading" aria-live="polite" aria-label="Generating board…">
+      <div class="loading-box">
+        <p class="loading-label">Generating…</p>
+        <div class="progress-track">
+          <div id="progress-bar" class="progress-bar"></div>
+        </div>
+      </div>
+    </div>
     <script type="module" src="/src/main.ts"></script>
   </body>
 </html>
diff --git a/readme.md b/readme.md
index dd6eb3f..be31e3a 100644
--- a/readme.md
+++ b/readme.md
@@ -56,6 +56,11 @@ The code also lives in the URL as `?code=<code>`, so a link reproduces the exact
 board. **🎲 Random** rolls a whole new config; the 🎲 next to Terrain rerolls just
 the terrain while keeping the other settings.
 
+Each axis also has a **lock** (🔓/🔒) toggle. Locking an axis pins it: **🎲 Random**
+then rerolls every *other* setting while leaving the locked ones untouched — so you
+can, say, freeze the size and walls and keep rolling only the terrain and springs.
+Locks are a session convenience and aren't part of the shared code.
+
 ## Develop
 
 ```bash
diff --git a/src/game.ts b/src/game.ts
index 5e15310..37f150b 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -1,53 +1,87 @@
-import { generatePuzzle, isSolutionSpring } from './model/puzzle';
 import type { Config, Puzzle } from './model/puzzle';
+import { isSolutionSpring } from './model/puzzle';
 import { simulateFlows } from './model/flow';
 import type { Cell } from './model/flow';
 import { Renderer } from './render/renderer';
+import type { WorkerRequest, WorkerResponse } from './model/puzzle.worker';
 
 export class Game {
-  private puzzle!: Puzzle;
-  /** Locked springs found so far, in discovery order. */
+  private puzzle: Puzzle | null = null;
   private found: Cell[] = [];
-  /** Current transient exploration tap (replaced by the next non-spring tap). */
   private probe: Cell | null = null;
   private solved = false;
   private renderer: Renderer;
 
+  private worker!: Worker;
+  private seq = 0;
+  private pendingResolve: (() => void) | null = null;
+
   constructor(
     private canvas: HTMLCanvasElement,
     private statusEl: HTMLElement,
+    private onProgress?: (fraction: number) => void,
   ) {
     this.renderer = new Renderer(canvas);
     canvas.addEventListener('click', (e) => this.onClick(e));
-    window.addEventListener('resize', () => this.draw());
+    window.addEventListener('resize', () => { if (this.puzzle) this.draw(); });
+    this.spawnWorker();
   }
 
-  /** Load a board from a config, clearing any progress. */
-  load(config: Config): void {
-    this.puzzle = generatePuzzle(config);
-    this.found = [];
-    this.probe = null;
-    this.solved = false;
-    this.updateStatus();
-    this.draw();
+  private spawnWorker(): void {
+    this.worker = new Worker(new URL('./model/puzzle.worker.ts', import.meta.url), {
+      type: 'module',
+    });
+    this.worker.addEventListener('message', (e: MessageEvent<WorkerResponse>) => {
+      this.onWorkerMessage(e.data);
+    });
+  }
+
+  /** Load a board from a config, clearing any progress. Returns when the board is ready. */
+  load(config: Config): Promise<void> {
+    // Terminate any in-flight generation to avoid stale results.
+    this.worker.terminate();
+    this.pendingResolve?.();
+    this.pendingResolve = null;
+    this.spawnWorker();
+
+    const seq = ++this.seq;
+    this.worker.postMessage({ type: 'generate', seq, config } satisfies WorkerRequest);
+
+    return new Promise((resolve) => {
+      this.pendingResolve = resolve;
+    });
+  }
+
+  private onWorkerMessage(data: WorkerResponse): void {
+    if (data.seq !== this.seq) return;
+
+    if (data.type === 'progress') {
+      this.onProgress?.(data.value);
+    } else if (data.type === 'done') {
+      this.puzzle = data.puzzle;
+      this.found = [];
+      this.probe = null;
+      this.solved = false;
+      this.updateStatus();
+      this.draw();
+      this.pendingResolve?.();
+      this.pendingResolve = null;
+    }
   }
 
   private onClick(e: MouseEvent): void {
-    if (this.solved) return;
+    if (!this.puzzle || this.solved) return;
     const rect = this.canvas.getBoundingClientRect();
     const cell = this.renderer.pickCell(this.puzzle.hm, e.clientX - rect.left, e.clientY - rect.top);
     if (!cell) return;
 
-    // Found springs are locked (immutable): tapping one again does nothing.
     if (this.found.some((f) => f.x === cell.x && f.y === cell.y)) return;
 
     if (isSolutionSpring(this.puzzle, cell)) {
-      // Correct position — lock it in and clear the transient probe.
       this.found.push(cell);
       this.probe = null;
       if (this.found.length === this.puzzle.solution.length) this.solved = true;
     } else {
-      // Exploration only: one probe at a time, alongside the locked springs.
       this.probe = cell;
     }
 
@@ -55,12 +89,12 @@ export class Game {
     this.draw();
   }
 
-  /** Springs driving the currently-shown flow: locked + the live probe. */
   private currentSprings(): Cell[] {
     return this.probe ? [...this.found, this.probe] : this.found;
   }
 
   private updateStatus(): void {
+    if (!this.puzzle) return;
     const n = this.puzzle.solution.length;
     const springs = n === 1 ? 'spring' : 'springs';
     this.statusEl.textContent = this.solved
@@ -69,6 +103,7 @@ export class Game {
   }
 
   private draw(): void {
+    if (!this.puzzle) return;
     const springs = this.currentSprings();
     const flow = springs.length ? simulateFlows(this.puzzle.hm, springs) : null;
     this.renderer.render(this.puzzle.hm, flow, {
diff --git a/src/main.ts b/src/main.ts
index 40f9aad..5afee59 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,6 +2,7 @@ import './style.css';
 import logoUrl from '../water.png';
 import { Game } from './game';
 import {
+  SIZE_MAX,
   decodeConfig,
   encodeConfig,
   normalizeConfig,
@@ -20,10 +21,23 @@ const $ = <T extends HTMLElement>(sel: string): T => document.querySelector<T>(s
 
 const canvas = $<HTMLCanvasElement>('#board');
 const status = $<HTMLElement>('#status');
-const game = new Game(canvas, status);
+const loadingEl = $<HTMLElement>('#loading');
+const progressBarEl = $<HTMLElement>('#progress-bar');
+
+function setProgress(fraction: number): void {
+  progressBarEl.style.width = `${Math.round(fraction * 100)}%`;
+}
+
+function showLoading(on: boolean): void {
+  loadingEl.classList.toggle('loading--visible', on);
+  if (on) setProgress(0);
+}
+
+const game = new Game(canvas, status, setProgress);
 
 // Config controls.
 const sizeInput = $<HTMLInputElement>('#cfg-size');
+sizeInput.max = String(SIZE_MAX);
 const sizeVal = $<HTMLElement>('#cfg-size-val');
 const springsInput = $<HTMLInputElement>('#cfg-springs');
 const erosionInput = $<HTMLInputElement>('#cfg-erosion');
@@ -61,14 +75,56 @@ function syncControls(): void {
 }
 
 /** Adopt a new config: normalise it, rebuild the board, resync the UI. */
-function apply(next: Config): void {
+async function apply(next: Config): Promise<void> {
   config = normalizeConfig(next);
-  game.load(config);
   syncControls();
+  showLoading(true);
+  await game.load(config);
+  showLoading(false);
 }
 
 const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean');
 
+// Which config axes are pinned against Randomize. Session-only: locks aren't part
+// of a board (the code encodes the config, not what you froze while rolling).
+type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'terrainSeed';
+const locks: Record<LockAxis, boolean> = {
+  size: false,
+  springCount: false,
+  edges: false,
+  erosion: false,
+  terrainSeed: false,
+};
+
+/** Reflect a lock button's state into its icon, label, and its control's styling. */
+function syncLock(btn: HTMLButtonElement, on: boolean): void {
+  btn.textContent = on ? '🔒' : '🔓';
+  btn.setAttribute('aria-pressed', String(on));
+  btn.title = on ? 'Locked — Random won’t change this' : 'Lock this against Random';
+  btn.closest('.ctl')?.classList.toggle('locked', on);
+}
+
+for (const btn of document.querySelectorAll<HTMLButtonElement>('.lock')) {
+  const axis = btn.dataset.lock as LockAxis;
+  syncLock(btn, locks[axis]);
+  btn.addEventListener('click', () => {
+    locks[axis] = !locks[axis];
+    syncLock(btn, locks[axis]);
+  });
+}
+
+/** A fresh random config, but every locked axis keeps its current value. */
+function randomizeUnlocked(): Config {
+  const r = randomConfig();
+  return {
+    size: locks.size ? config.size : r.size,
+    springCount: locks.springCount ? config.springCount : r.springCount,
+    edges: locks.edges ? { ...config.edges } : r.edges,
+    erosion: locks.erosion ? config.erosion : r.erosion,
+    terrainSeed: locks.terrainSeed ? config.terrainSeed : r.terrainSeed,
+  };
+}
+
 /** Human tag for an erosion level, so the slider reads as more than a number. */
 function agingLabel(n: number): string {
   const word = n === 0 ? 'pristine' : n <= 4 ? 'weathered' : n <= 9 ? 'eroded' : 'dissected';
@@ -104,7 +160,7 @@ codeInput.addEventListener('change', () => {
   if (decoded) apply(decoded);
   else syncControls(); // reject: snap back to the current config
 });
-$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomConfig()));
+$<HTMLButtonElement>('#randomize').addEventListener('click', () => apply(randomizeUnlocked()));
 
 // Start from a shared code if present, otherwise a fresh random board.
 const codeParam = new URLSearchParams(location.search).get('code');
diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts
index 50f42f3..e3230e6 100644
--- a/src/model/puzzle.ts
+++ b/src/model/puzzle.ts
@@ -28,7 +28,7 @@ export interface Config {
 }
 
 export const SIZE_MIN = 5;
-export const SIZE_MAX = 20;
+export const SIZE_MAX = 100;
 export const SPRINGS_MIN = 1;
 export const SPRINGS_MAX = 6;
 export const EROSION_MIN = 0;
@@ -65,11 +65,16 @@ export interface Puzzle {
  * {@link isUniqueSolution}). Boards that can't be made unique fall back to the
  * richest set found, so generation never fails.
  */
-export function generatePuzzle(config: Config): Puzzle {
+export function generatePuzzle(
+  config: Config,
+  onProgress?: (fraction: number) => void,
+): Puzzle {
+  onProgress?.(0);
   const norm = normalizeConfig(config);
   const { size, springCount, edges, erosion, terrainSeed } = norm;
   const rng = mulberry32(terrainSeed);
   const hm = generateDome(size, size, rng, edges, erosion);
+  onProgress?.(0.1);
 
   // Candidates: interior (non-edge) land cells. After drainage resolution every
   // interior cell has height > 0 and a strictly-decreasing path to an ocean edge.
@@ -84,13 +89,40 @@ export function generatePuzzle(config: Config): Puzzle {
   // one once as a bitmask over border-cell indices. Because wet sets (and hence
   // outflow) union across springs, a spring set's target signature is simply the
   // OR of its members' masks — which is what makes the uniqueness check cheap.
-  const sig = candidates.map((c) => outflowMask(simulateFlows(hm, [c]).outflow, size));
+  const sigStart = performance.now();
+  const sig: bigint[] = [];
+  for (let i = 0; i < candidates.length; i++) {
+    sig.push(outflowMask(simulateFlows(hm, [candidates[i]]).outflow, size));
+    if ((i & 63) === 0) onProgress?.(0.1 + 0.1 * ((i + 1) / candidates.length));
+  }
+  const sigMs = Math.max(1, performance.now() - sigStart);
+
+  // countCovers (the uniqueness DFS) uses bigint arithmetic, which is empirically
+  // ~10× slower than the float32 array ops in simulateFlows. Use the measured sig
+  // rate to estimate how long the 128 uniqueness-check attempts will take, then
+  // give each phase its proportional share of the progress bar.
+  const BIGINT_FACTOR = 10;
+  const perSigMs = sigMs / Math.max(1, candidates.length);
+  const compatEst = Math.floor(candidates.length * 0.4); // ~40% of candidates fit any target
+  const BUDGET = 200_000;
+  const attemptsOpsEst = Math.min(BUDGET, Math.pow(compatEst, springCount) / springCount);
+  const ATTEMPTS = 128;
+  const perAttemptMs = (attemptsOpsEst * perSigMs * BIGINT_FACTOR) / Math.max(1, (size * size) / 2);
+  const attemptsEstMs = Math.max(1, ATTEMPTS * perAttemptMs);
+
+  // Snap bar to sig's real proportional share, then fill the rest during attempts.
+  const totalEstMs = sigMs + attemptsEstMs;
+  const P_SIG_END = 0.1 + 0.88 * (sigMs / totalEstMs);
+  onProgress?.(P_SIG_END);
 
   const wanted = Math.max(3, springCount); // prefer a signature at least this wide
   let bestUnique: Candidate | null = null;
   let bestAny: Candidate | null = null;
 
-  for (let attempt = 0; attempt < 128; attempt++) {
+  const attemptsStart = performance.now();
+  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
+    const elapsed = performance.now() - attemptsStart;
+    onProgress?.(P_SIG_END + (0.99 - P_SIG_END) * Math.min(0.98, elapsed / attemptsEstMs));
     // Relax the spacing requirement if the board is too cramped to satisfy it.
     const minGap = attempt < 96 ? 3 : attempt < 112 ? 2 : 1;
     const idx = pickSpringIdx(candidates, springCount, rng, minGap);
@@ -110,6 +142,7 @@ export function generatePuzzle(config: Config): Puzzle {
       if (richness >= wanted) break; // unique *and* rich enough — good to go
     }
   }
+  onProgress?.(0.99);
 
   // Prefer an unambiguous board; fall back to the richest set otherwise. `bestAny`
   // is always set: normalizeConfig guarantees at least `springCount` interior
@@ -119,6 +152,7 @@ export function generatePuzzle(config: Config): Puzzle {
   const targetOutflow = simulateFlows(hm, chosen.solution).outflow;
   const target = borderSignature(targetOutflow, size, size);
 
+  onProgress?.(1);
   return {
     config: norm,
     hm,
diff --git a/src/model/puzzle.worker.ts b/src/model/puzzle.worker.ts
new file mode 100644
index 0000000..e1c103f
--- /dev/null
+++ b/src/model/puzzle.worker.ts
@@ -0,0 +1,21 @@
+import { generatePuzzle } from './puzzle';
+import type { Config, Puzzle } from './puzzle';
+
+export type WorkerRequest = { type: 'generate'; seq: number; config: Config };
+export type WorkerResponse =
+  | { type: 'progress'; seq: number; value: number }
+  | { type: 'done'; seq: number; puzzle: Puzzle };
+
+self.addEventListener('message', (e: MessageEvent<WorkerRequest>) => {
+  const { type, seq, config } = e.data;
+  if (type !== 'generate') return;
+
+  const puzzle = generatePuzzle(config, (value) => {
+    (self as unknown as Worker).postMessage({ type: 'progress', seq, value } satisfies WorkerResponse);
+  });
+
+  (self as unknown as Worker).postMessage(
+    { type: 'done', seq, puzzle } satisfies WorkerResponse,
+    [puzzle.hm.data.buffer, puzzle.target.buffer],
+  );
+});
diff --git a/src/style.css b/src/style.css
index 3735dd7..1388a1c 100644
--- a/src/style.css
+++ b/src/style.css
@@ -101,6 +101,43 @@ header button {
   opacity: 0.85;
 }
 
+/* Header row of a control: its label on the left, its lock toggle on the right. */
+.ctl-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+/* Small, unobtrusive icon button that pins a config axis against Randomize. */
+.lock {
+  padding: 0 3px;
+  background: none;
+  color: inherit;
+  font-size: 0.85rem;
+  line-height: 1.2;
+  border-radius: 4px;
+  opacity: 0.4;
+}
+
+.lock:hover {
+  background: rgba(255, 255, 255, 0.08);
+  opacity: 0.85;
+}
+
+.lock[aria-pressed='true'] {
+  opacity: 1;
+}
+
+/* Locked axes read at full strength and get an accent rail, so it's obvious at a
+   glance which settings Randomize will leave untouched. */
+.ctl.locked {
+  opacity: 1;
+  border-left: 2px solid #2d6cdf;
+  margin-left: -8px;
+  padding-left: 6px;
+}
+
 .ctl .val {
   opacity: 0.6;
   font-variant-numeric: tabular-nums;
@@ -157,7 +194,17 @@ header button {
 }
 
 .edges legend {
+  /* Full-width so the lock toggle can sit at the far right, beside a dimmed
+     "Walls" label (dimming lives on the label span, not the whole legend). */
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
   padding: 0 4px;
+  box-sizing: border-box;
+}
+
+.edges legend > span {
   opacity: 0.6;
 }
 
@@ -198,6 +245,62 @@ canvas {
   color: inherit;
 }
 
+/* Loading overlay — covers the whole viewport while the worker generates a board. */
+.loading {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(11, 14, 20, 0.75);
+  backdrop-filter: blur(4px);
+  z-index: 100;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.15s ease;
+}
+
+.loading--visible {
+  opacity: 1;
+  pointer-events: all;
+}
+
+.loading-box {
+  background: #1a2030;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 14px;
+  padding: 24px 32px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 14px;
+  min-width: 220px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+}
+
+.loading-label {
+  margin: 0;
+  font-size: 0.95rem;
+  opacity: 0.8;
+  letter-spacing: 0.02em;
+}
+
+.progress-track {
+  width: 100%;
+  height: 6px;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.progress-bar {
+  height: 100%;
+  width: 0%;
+  background: #2d6cdf;
+  border-radius: 3px;
+  transition: width 0.1s linear;
+}
+
 /* Narrow screens: fall back to the stacked layout, controls above the board. */
 @media (max-width: 720px) {
   #app {