PageSourceSearch

https://choxos.github.io/game-of-life/js/cells.js

js choxos.github.io collected 2026-10-03 08:48:09 UTC 6,163 bytes, 167 lines download raw bytes

1/* Game of Life: square cells on a canvas that glide from one layout to the next. */
2(function (root) {
3  const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
4  const lerp = (a, b, t) => a + (b - a) * t;
5  const ease = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
6  const reduced = root.matchMedia('(prefers-reduced-motion: reduce)');
7
8  // Largest pitch (cell plus gap) that fits n square cells in w by h, and the column count that gets it.
9  function pack(n, w, h, max = Infinity) {
10    let best = { cols: 1, p: Math.min(w, h, max) };
11    for (let cols = 1; cols <= n; cols++) {
12      if (w / cols < best.p) break; // more columns only shrink the cells from here on
13      const p = Math.min(w / cols, h / Math.ceil(n / cols), max);
14      if (p > best.p || cols === 1) best = { cols, p };
15    }
16    return best;
17  }
18
19  // A scene is a function (width, height) => { cells: [...], labels: [...] } in CSS pixels.
20  // Cell: { k, x, y, s, c: [r, g, b], a?, d? (delay in ms), from?: { x, y, s }, group?, info? }.
21  // `group` ties cells together for hover emphasis; `info` is whatever the page shows on hover.
22  // Label: { k, text, x, y, font, color, align? }.
23  function createCells(canvas, { duration = 900 } = {}) {
24    const ctx = canvas.getContext('2d');
25    const cells = new Map();
26    const labels = new Map();
27    let W = 0, H = 0, dpr = 1, t0 = 0, running = false, scene = null, instant = false;
28    let hover = null; // { group?, key?, stroke? }: dims other groups and outlines one cell
29
30    function measure() {
31      const r = canvas.getBoundingClientRect();
32      dpr = Math.min(root.devicePixelRatio || 1, 2);
33      W = r.width;
34      H = r.height;
35      const w = Math.max(1, Math.round(W * dpr)), h = Math.max(1, Math.round(H * dpr));
36      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
37    }
38
39    function begin(item, to, d) {
40      item.from = instant ? to : Object.fromEntries(Object.keys(to).map(key => [key, item[key]]));
41      item.to = to;
42      item.d = d;
43      item.dead = false;
44    }
45
46    function show(next, animate = true) {
47      scene = next;
48      measure();
49      if (W < 1 || H < 1) return;
50      instant = !animate || reduced.matches;
51      const out = scene(W, H);
52
53      const seen = new Set();
54      for (const t of out.cells) {
55        seen.add(t.k);
56        const to = { x: t.x, y: t.y, s: t.s, a: t.a ?? 1, r: t.c[0], g: t.c[1], b: t.c[2] };
57        let c = cells.get(t.k);
58        if (!c) {
59          const f = t.from || { x: t.x + t.s / 2, y: t.y + t.s / 2, s: 0 };
60          c = { ...to, x: f.x, y: f.y, s: f.s, k: t.k };
61          cells.set(t.k, c);
62        }
63        c.group = t.group;
64        c.info = t.info;
65        begin(c, to, t.d || 0);
66      }
67      for (const [k, c] of cells) {
68        if (seen.has(k)) continue;
69        begin(c, { x: c.x + c.s / 4, y: c.y + c.s / 4, s: c.s / 2, a: 0 }, 0);
70        c.dead = true;
71      }
72
73      const seenLabels = new Set();
74      for (const t of out.labels || []) {
75        seenLabels.add(t.k);
76        let l = labels.get(t.k);
77        if (!l) { l = { x: t.x, y: t.y, a: 0 }; labels.set(t.k, l); }
78        Object.assign(l, { text: t.text, font: t.font, color: t.color, align: t.align || 'left' });
79        begin(l, { x: t.x, y: t.y, a: 1 }, 0);
80      }
81      for (const [k, l] of labels) {
82        if (seenLabels.has(k)) continue;
83        begin(l, { a: 0 }, 0);
84        l.dead = true;
85      }
86
87      t0 = performance.now();
88      if (instant) tick(Infinity);
89      else if (!running) { running = true; requestAnimationFrame(frame); }
90    }
91
92    function tick(T) {
93      let busy = false;
94      for (const map of [cells, labels]) {
95        for (const [k, item] of map) {
96          const p = clamp((T - item.d) / duration, 0, 1), e = ease(p);
97          for (const key in item.to) item[key] = lerp(item.from[key], item.to[key], e);
98          if (p < 1) busy = true;
99          else if (item.dead) map.delete(k);
100        }
101      }
102      draw();
103      return busy;
104    }
105
106    function frame(now) {
107      if (tick(now - t0)) requestAnimationFrame(frame);
108      else running = false;
109    }
110
111    function draw() {
112      const px = v => Math.round(v * dpr) / dpr;
113      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
114      ctx.clearRect(0, 0, W, H);
115      let fill = '';
116      const dim = hover && hover.group !== undefined;
117      for (const c of cells.values()) {
118        if (c.a < 0.02 || c.s < 0.3) continue;
119        const f = `rgb(${c.r | 0},${c.g | 0},${c.b | 0})`;
120        if (f !== fill) { ctx.fillStyle = f; fill = f; }
121        ctx.globalAlpha = dim && c.group !== hover.group ? c.a * 0.25 : c.a;
122        const s = Math.max(1 / dpr, px(c.s));
123        ctx.fillRect(px(c.x), px(c.y), s, s);
124      }
125      const lifted = hover && hover.key !== undefined && cells.get(hover.key);
126      if (lifted && lifted.a > 0.02) {
127        const s = Math.max(1 / dpr, px(lifted.s));
128        ctx.globalAlpha = 1;
129        ctx.strokeStyle = hover.stroke || '#000';
130        ctx.lineWidth = 1.5;
131        ctx.strokeRect(px(lifted.x) - 1, px(lifted.y) - 1, s + 2, s + 2);
132      }
133      ctx.textBaseline = 'middle';
134      for (const l of labels.values()) {
135        if (l.a < 0.02) continue;
136        ctx.globalAlpha = l.a;
137        ctx.font = l.font;
138        ctx.fillStyle = l.color;
139        ctx.textAlign = l.align;
140        ctx.fillText(l.text, l.x, l.y);
141      }
142      ctx.globalAlpha = 1;
143    }
144
145    // The visible cell under a point, in CSS pixels. The target includes the gap around
146    // each square so the pointer never falls between two of them.
147    function hit(x, y) {
148      for (const c of cells.values()) {
149        if (c.dead || !c.to || c.to.a <= 0.05) continue;
150        const pad = Math.max(1, c.to.s * 0.2);
151        if (x >= c.to.x - pad && x <= c.to.x + c.to.s + pad && y >= c.to.y - pad && y <= c.to.y + c.to.s + pad) return c;
152      }
153      return null;
154    }
155
156    function setHover(next) {
157      hover = next;
158      if (!running) draw();
159    }
160
161    new ResizeObserver(() => { if (scene) show(scene, false); }).observe(canvas);
162
163    return { show, hit, setHover };
164  }
165
166  root.Cells = { createCells, pack };
167})(window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.