| 1 | /* codewhale-ocean: ambient ocean scene behind the DSH web UI. |
| 2 | * |
| 3 | * Plain script (no module syntax) so it can be spliced verbatim into the |
| 4 | * bundle's lib/client.js — dsh-client-modules serves exactly one file per |
| 5 | * client plugin (`/plugins/<id>/client.js`), so a sibling scene.js would |
| 6 | * never be fetched. Exposes `createOcean(palette)`; the caller mounts it. |
| 7 | * |
| 8 | * Contract: one full-viewport <canvas> (fixed, inset 0, z-index -1, |
| 9 | * pointer-events none) painted below the app root and above the body |
| 10 | * background. ~30 fps, paused while the document is hidden, one static |
| 11 | * frame under prefers-reduced-motion, no per-frame allocations, DPR-aware. |
| 12 | * Off switch: body class `codewhale-ocean-off` or |
| 13 | * localStorage["codewhale.ocean"] === "off". `palette` carries |
| 14 | * { light: {base, accent, ink, dim}, dark: {...} } CSS hex colors taken from |
| 15 | * the skin token table. |
| 16 | */ |
| 17 | function createOcean(palette) { |
| 18 | var STORAGE_KEY = "codewhale.ocean"; |
| 19 | var OFF_CLASS = "codewhale-ocean-off"; |
| 20 | var FRAME_MS = 1000 / 30; |
| 21 | var FISH_COUNT = 16; |
| 22 | var BUBBLE_COUNT = 26; |
| 23 | var TAU = Math.PI * 2; |
| 24 | var MONO = '14px "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace'; |
| 25 | |
| 26 | function isOff() { |
| 27 | try { |
| 28 | if (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY) === "off") return true; |
| 29 | } catch (_) {} |
| 30 | return !!(document.body && document.body.classList.contains(OFF_CLASS)); |
| 31 | } |
| 32 | |
| 33 | function hexToRgb(hex) { |
| 34 | var h = String(hex).replace("#", ""); |
| 35 | if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; |
| 36 | var n = parseInt(h, 16); |
| 37 | if (isNaN(n)) return [128, 128, 128]; |
| 38 | return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; |
| 39 | } |
| 40 | function mix(a, b, t) { |
| 41 | return [ |
| 42 | Math.round(a[0] + (b[0] - a[0]) * t), |
| 43 | Math.round(a[1] + (b[1] - a[1]) * t), |
| 44 | Math.round(a[2] + (b[2] - a[2]) * t), |
| 45 | ]; |
| 46 | } |
| 47 | function rgba(c, a) { |
| 48 | return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; |
| 49 | } |
| 50 | |
| 51 | // Per-scheme colour set, precomputed once per scheme switch (no strings |
| 52 | // are built inside the frame loop). |
| 53 | function buildColors(scheme) { |
| 54 | var p = palette[scheme] || palette.dark; |
| 55 | var base = hexToRgb(p.base); |
| 56 | var accent = hexToRgb(p.accent); |
| 57 | var ink = hexToRgb(p.ink); |
| 58 | var dim = hexToRgb(p.dim); |
| 59 | var dark = scheme === "dark"; |
| 60 | return { |
| 61 | dark: dark, |
| 62 | // Depth gradient: light water reads darker/bluer with depth; dark |
| 63 | // water is lit faintly from above and falls to the base at the floor. |
| 64 | top: dark ? rgba(mix(base, accent, 0.16), 1) : rgba(base, 1), |
| 65 | bottom: dark ? rgba(base, 1) : rgba(mix(base, accent, 0.17), 1), |
| 66 | whaleNear: dark ? mix(dim, accent, 0.58) : mix(ink, accent, 0.36), |
| 67 | whaleFar: dark ? mix(dim, base, 0.12) : mix(dim, accent, 0.3), |
| 68 | fish: dark ? mix(accent, dim, 0.15) : mix(accent, ink, 0.1), |
| 69 | bubble: dark ? mix(dim, accent, 0.4) : mix(accent, dim, 0.3), |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | function detectScheme() { |
| 74 | var cs = document.documentElement.style.colorScheme; |
| 75 | if (cs === "dark" || cs === "light") return cs; |
| 76 | if (document.body && document.body.hasAttribute("data-ds-dark-theme")) return "dark"; |
| 77 | if (typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches) return "dark"; |
| 78 | return "light"; |
| 79 | } |
| 80 | |
| 81 | function reducedMotion() { |
| 82 | return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 83 | } |
| 84 | |
| 85 | // Deterministic PRNG so two frames of the same seed are comparable and |
| 86 | // the scene does not depend on Math.random ordering. |
| 87 | var seed = 0x2f6e2b1; |
| 88 | function rand() { |
| 89 | seed = (seed * 1664525 + 1013904223) >>> 0; |
| 90 | return seed / 4294967296; |
| 91 | } |
| 92 | |
| 93 | // ---- Whale -------------------------------------------------------------- |
| 94 | // Unit-space silhouette facing +x, ~5:1 length:height: fluke tip near x=0, |
| 95 | // snout at x=1, spine on y=0. Drawn with the CTM scaled to the whale's |
| 96 | // length, so every number here is a proportion. Blunt rounded head, long |
| 97 | // flat back with a low soft dorsal hump about two thirds back, slightly |
| 98 | // convex belly, thin tail stock, and a HORIZONTAL fluke seen from the |
| 99 | // side: a thin swept blade with a slight downward curl (never a vertical |
| 100 | // fish tail). The fluke is added under a rotation about the tail stock |
| 101 | // (0.11, 0) — canvas paths capture the CTM per segment, so the flex needs |
| 102 | // no point math. |
| 103 | function traceWhale(g, flex) { |
| 104 | g.moveTo(1.0, 0.02); |
| 105 | // forehead and back |
| 106 | g.bezierCurveTo(1.0, -0.035, 0.975, -0.085, 0.9, -0.095); |
| 107 | g.bezierCurveTo(0.8, -0.105, 0.68, -0.1, 0.55, -0.095); |
| 108 | // low dorsal hump, soft |
| 109 | g.bezierCurveTo(0.47, -0.095, 0.4, -0.105, 0.34, -0.118); |
| 110 | g.bezierCurveTo(0.3, -0.105, 0.26, -0.085, 0.22, -0.06); |
| 111 | // peduncle tapers thin into the tail stock |
| 112 | g.bezierCurveTo(0.18, -0.04, 0.14, -0.024, 0.11, -0.02); |
| 113 | // horizontal fluke: a wide, low, notched T seen with a hint of |
| 114 | // perspective — lobes sweep BACK (width > height), the lower lobe a |
| 115 | // touch longer for the downward curl. Never a vertical fish tail. |
| 116 | g.save(); |
| 117 | g.translate(0.11, 0); |
| 118 | g.rotate(flex); |
| 119 | g.translate(-0.11, 0); |
| 120 | g.bezierCurveTo(0.07, -0.04, 0.0, -0.05, -0.08, -0.065); |
| 121 | g.bezierCurveTo(-0.03, -0.04, -0.01, -0.012, 0.005, 0.0); |
| 122 | g.bezierCurveTo(-0.01, 0.012, -0.03, 0.045, -0.085, 0.075); |
| 123 | g.bezierCurveTo(0.0, 0.055, 0.07, 0.04, 0.11, 0.02); |
| 124 | g.restore(); |
| 125 | // belly, slightly convex, up to the jaw |
| 126 | g.bezierCurveTo(0.2, 0.05, 0.35, 0.09, 0.52, 0.1); |
| 127 | g.bezierCurveTo(0.72, 0.11, 0.9, 0.09, 0.98, 0.05); |
| 128 | g.bezierCurveTo(1.0, 0.04, 1.0, 0.03, 1.0, 0.02); |
| 129 | g.closePath(); |
| 130 | } |
| 131 | // One long pectoral flipper (~1/3 body length) sweeping down and back from |
| 132 | // a third of the way along the body — the humpback cue. |
| 133 | function tracePectoral(g) { |
| 134 | g.moveTo(0.72, 0.09); |
| 135 | g.bezierCurveTo(0.66, 0.135, 0.5, 0.205, 0.38, 0.225); |
| 136 | g.bezierCurveTo(0.47, 0.18, 0.58, 0.115, 0.65, 0.075); |
| 137 | g.closePath(); |
| 138 | } |
| 139 | |
| 140 | // One crossing = a slow linear glide with a gentle sine in Y. Fields in |
| 141 | // a flat Float64Array: [x0, x1, yBase, yAmp, yFreq, dur, start, len, |
| 142 | // phase, spoutAt]. Paths are biased to the lower half (near) and the top |
| 143 | // edge (far) so neither whale swims through the composer card. |
| 144 | var SPOUT_N = 10, SB = 4; // spout bubbles: [x, y, vy, life] |
| 145 | function Whale(near) { |
| 146 | this.near = near; |
| 147 | this.p = new Float64Array(10); |
| 148 | this.dir = 1; |
| 149 | this.spout = new Float64Array(SPOUT_N * SB); |
| 150 | this.x = 0; this.y = 0; |
| 151 | } |
| 152 | Whale.prototype.reset = function (W, H, t, first) { |
| 153 | var p = this.p; |
| 154 | var near = this.near; |
| 155 | var len = near ? Math.max(240, Math.min(0.34 * W, 560)) : Math.max(130, Math.min(0.18 * W, 300)); |
| 156 | this.dir = rand() < 0.5 ? -1 : 1; |
| 157 | var band0 = near ? 0.66 : 0.07; |
| 158 | var band1 = near ? 0.9 : 0.24; |
| 159 | p[2] = H * (band0 + rand() * (band1 - band0)); |
| 160 | p[3] = H * (0.02 + rand() * 0.025); |
| 161 | p[4] = 0.12 + rand() * 0.08; |
| 162 | var margin = len * 1.15; |
| 163 | p[0] = this.dir > 0 ? -margin : W + margin; |
| 164 | p[1] = this.dir > 0 ? W + margin : -margin; |
| 165 | p[5] = (near ? 75 : 110) * (0.85 + rand() * 0.3); // seconds per crossing |
| 166 | // First whale starts mid-crossing so the scene is never empty on load. |
| 167 | p[6] = first ? t - p[5] * (0.3 + rand() * 0.25) : t + (near ? 4 : 6) + rand() * 10; |
| 168 | p[7] = len; |
| 169 | p[8] = rand() * TAU; |
| 170 | p[9] = t + 6 + rand() * 12; |
| 171 | for (var i = 0; i < SPOUT_N; i++) this.spout[i * SB + 3] = 0; |
| 172 | }; |
| 173 | Whale.prototype.step = function (t, dt, W, H) { |
| 174 | var p = this.p; |
| 175 | var u = (t - p[6]) / p[5]; |
| 176 | if (u < 0) return; |
| 177 | if (u > 1) { this.reset(W, H, t, false); return; } |
| 178 | // ease the ends a little so entry/exit are calm |
| 179 | var e = u * u * (3 - 2 * u) * 0.35 + u * 0.65; |
| 180 | this.x = p[0] + (p[1] - p[0]) * e; |
| 181 | this.y = p[2] + Math.sin(t * p[4] + p[8]) * p[3]; |
| 182 | // spout: a short bubble stream from the head when in the top third |
| 183 | var sp = this.spout; |
| 184 | if (this.y < H / 3 && t > p[9] && t < p[9] + 1.6) { |
| 185 | for (var i = 0; i < SPOUT_N; i++) { |
| 186 | var o = i * SB; |
| 187 | if (sp[o + 3] > 0) continue; |
| 188 | if (rand() < dt * 6) { |
| 189 | sp[o] = this.x + this.dir * p[7] * 0.9; |
| 190 | sp[o + 1] = this.y - p[7] * 0.09; |
| 191 | sp[o + 2] = 18 + rand() * 14; |
| 192 | sp[o + 3] = 1.2 + rand() * 0.8; |
| 193 | } |
| 194 | } |
| 195 | } else if (t >= p[9] + 1.6) { |
| 196 | p[9] = t + 14 + rand() * 16; |
| 197 | } |
| 198 | for (var j = 0; j < SPOUT_N; j++) { |
| 199 | var q = j * SB; |
| 200 | if (sp[q + 3] <= 0) continue; |
| 201 | sp[q + 3] -= dt; |
| 202 | sp[q + 1] -= sp[q + 2] * dt; |
| 203 | sp[q] += Math.sin(t * 2 + j) * 4 * dt; |
| 204 | } |
| 205 | }; |
| 206 | Whale.prototype.draw = function (g, t, alpha) { |
| 207 | var p = this.p; |
| 208 | var u = (t - p[6]) / p[5]; |
| 209 | if (u < 0 || u > 1) return; |
| 210 | // pitch follows the sine's slope, kept gentle |
| 211 | var slope = Math.cos(t * p[4] + p[8]) * p[3] * p[4]; |
| 212 | var ang = Math.max(-0.12, Math.min(0.12, slope / (p[7] * 0.4))) * this.dir; |
| 213 | // tail flex: ±10° around the stock, slow, with a softer second harmonic |
| 214 | var flex = 0.14 * Math.sin(t * 0.9 + p[8]) + 0.035 * Math.sin(t * 1.8 + p[8]); |
| 215 | var L = p[7]; |
| 216 | g.save(); |
| 217 | g.translate(this.x, this.y); |
| 218 | g.rotate(ang); |
| 219 | g.scale(this.dir * L, L); |
| 220 | g.globalAlpha = alpha; |
| 221 | g.beginPath(); |
| 222 | traceWhale(g, flex); |
| 223 | tracePectoral(g); |
| 224 | g.fill(); |
| 225 | g.restore(); |
| 226 | }; |
| 227 | Whale.prototype.drawSpout = function (g, alpha) { |
| 228 | var sp = this.spout; |
| 229 | g.lineWidth = 1; |
| 230 | for (var i = 0; i < SPOUT_N; i++) { |
| 231 | var o = i * SB; |
| 232 | if (sp[o + 3] <= 0) continue; |
| 233 | g.globalAlpha = alpha * Math.min(1, sp[o + 3]); |
| 234 | g.beginPath(); |
| 235 | g.arc(sp[o], sp[o + 1], 1.4, 0, TAU); |
| 236 | g.stroke(); |
| 237 | } |
| 238 | }; |
| 239 | |
| 240 | // ---- Fish school (flocking-lite) ----------------------------------------- |
| 241 | // Layout: [x, y, vx, vy, ox, oy, size, phase, facing] |
| 242 | var FS = 9; |
| 243 | function initFish(fish, W, H) { |
| 244 | for (var i = 0; i < FISH_COUNT; i++) { |
| 245 | var o = i * FS; |
| 246 | var a = rand() * TAU, r = 30 + rand() * 100; |
| 247 | fish[o + 4] = Math.cos(a) * r * 1.6; |
| 248 | fish[o + 5] = Math.sin(a) * r * 0.55; |
| 249 | fish[o + 0] = W * 0.5 + fish[o + 4]; |
| 250 | fish[o + 1] = H * 0.55 + fish[o + 5]; |
| 251 | fish[o + 2] = 0; fish[o + 3] = 0; |
| 252 | fish[o + 6] = 0.85 + rand() * 0.4; |
| 253 | fish[o + 7] = rand() * TAU; |
| 254 | fish[o + 8] = 1; |
| 255 | } |
| 256 | } |
| 257 | function stepFish(fish, t, dt, W, H) { |
| 258 | // leader on a slow lissajous inside the middle band |
| 259 | var lx = W * (0.5 + 0.36 * Math.sin(t * 0.055)); |
| 260 | var ly = H * (0.5 + 0.2 * Math.sin(t * 0.09 + 1.3)); |
| 261 | for (var i = 0; i < FISH_COUNT; i++) { |
| 262 | var o = i * FS; |
| 263 | var wob = fish[o + 7]; |
| 264 | var tx = lx + fish[o + 4] + Math.sin(t * 0.7 + wob) * 6; |
| 265 | var ty = ly + fish[o + 5] + Math.cos(t * 0.9 + wob) * 4; |
| 266 | var ax = (tx - fish[o]) * 0.9, ay = (ty - fish[o + 1]) * 0.9; |
| 267 | fish[o + 2] += ax * dt; fish[o + 3] += ay * dt; |
| 268 | // damping + speed cap |
| 269 | fish[o + 2] *= 0.985; fish[o + 3] *= 0.985; |
| 270 | var sp = Math.sqrt(fish[o + 2] * fish[o + 2] + fish[o + 3] * fish[o + 3]); |
| 271 | var cap = 55; |
| 272 | if (sp > cap) { fish[o + 2] *= cap / sp; fish[o + 3] *= cap / sp; } |
| 273 | fish[o] += fish[o + 2] * dt; fish[o + 1] += fish[o + 3] * dt; |
| 274 | // facing with hysteresis so glyphs don't flip-flop |
| 275 | if (fish[o + 2] > 6) fish[o + 8] = 1; else if (fish[o + 2] < -6) fish[o + 8] = -1; |
| 276 | } |
| 277 | } |
| 278 | function drawFish(g, fish, alpha) { |
| 279 | g.font = MONO; |
| 280 | g.textBaseline = "middle"; |
| 281 | g.textAlign = "center"; |
| 282 | for (var i = 0; i < FISH_COUNT; i++) { |
| 283 | var o = i * FS; |
| 284 | var big = (i % 5) === 0; |
| 285 | var right = fish[o + 8] > 0; |
| 286 | var glyph = big ? (right ? "><o>" : "<o><") : (right ? "><>" : "<><"); |
| 287 | g.globalAlpha = alpha * (0.55 + 0.45 * (fish[o + 6] - 0.85) / 0.4); |
| 288 | g.save(); |
| 289 | g.translate(fish[o], fish[o + 1]); |
| 290 | g.scale(fish[o + 6], fish[o + 6]); |
| 291 | g.fillText(glyph, 0, 0); |
| 292 | g.restore(); |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | // ---- Bubbles --------------------------------------------------------------- |
| 297 | // Layout: [x, y, r, speed, phase] |
| 298 | var BS = 5; |
| 299 | function resetBubble(b, o, W, H, fresh) { |
| 300 | b[o] = rand() * W; |
| 301 | b[o + 1] = fresh ? rand() * H : H + 10 + rand() * 40; |
| 302 | b[o + 2] = 0.8 + rand() * 1.9; |
| 303 | b[o + 3] = 9 + rand() * 14; |
| 304 | b[o + 4] = rand() * TAU; |
| 305 | } |
| 306 | function initBubbles(b, W, H) { |
| 307 | for (var i = 0; i < BUBBLE_COUNT; i++) resetBubble(b, i * BS, W, H, true); |
| 308 | } |
| 309 | function stepBubbles(b, t, dt, W, H) { |
| 310 | for (var i = 0; i < BUBBLE_COUNT; i++) { |
| 311 | var o = i * BS; |
| 312 | b[o + 1] -= b[o + 3] * dt; |
| 313 | b[o] += Math.sin(t * 0.8 + b[o + 4]) * 6 * dt; |
| 314 | if (b[o + 1] < -12) resetBubble(b, o, W, H, false); |
| 315 | } |
| 316 | } |
| 317 | function drawBubbles(g, b, alpha) { |
| 318 | g.lineWidth = 1; |
| 319 | for (var i = 0; i < BUBBLE_COUNT; i++) { |
| 320 | var o = i * BS; |
| 321 | g.globalAlpha = alpha * (0.35 + 0.4 * (b[o + 2] - 0.8) / 1.9); |
| 322 | g.beginPath(); |
| 323 | g.arc(b[o], b[o + 1], b[o + 2], 0, TAU); |
| 324 | g.stroke(); |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // ---- Scene ------------------------------------------------------------------ |
| 329 | var canvas = null, g = null; |
| 330 | var W = 0, H = 0, dpr = 1; |
| 331 | var colors = null, scheme = null; |
| 332 | var whales = [new Whale(false), new Whale(true)]; |
| 333 | var fish = new Float64Array(FISH_COUNT * FS); |
| 334 | var bubbles = new Float64Array(BUBBLE_COUNT * BS); |
| 335 | var gradient = null; |
| 336 | var raf = 0, running = false, mounted = false, lastFrame = 0, lastT = 0, t0 = 0; |
| 337 | var intensity = 1; |
| 338 | var staticOnly = false; |
| 339 | |
| 340 | function resize() { |
| 341 | var w = window.innerWidth, h = window.innerHeight; |
| 342 | var d = Math.min(2, window.devicePixelRatio || 1); |
| 343 | if (w === W && h === H && d === dpr && gradient) return; |
| 344 | var first = W === 0; |
| 345 | W = w; H = h; dpr = d; |
| 346 | canvas.width = Math.max(1, Math.round(W * dpr)); |
| 347 | canvas.height = Math.max(1, Math.round(H * dpr)); |
| 348 | g.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 349 | rebuildGradient(); |
| 350 | if (first) { |
| 351 | var t = (performance.now() - t0) / 1000; |
| 352 | whales[0].reset(W, H, t, false); |
| 353 | whales[1].reset(W, H, t, true); |
| 354 | initFish(fish, W, H); |
| 355 | initBubbles(bubbles, W, H); |
| 356 | } |
| 357 | } |
| 358 | function rebuildGradient() { |
| 359 | if (!g || !colors) return; |
| 360 | gradient = g.createLinearGradient(0, 0, 0, H); |
| 361 | gradient.addColorStop(0, colors.top); |
| 362 | gradient.addColorStop(1, colors.bottom); |
| 363 | } |
| 364 | function applyScheme(next) { |
| 365 | if (next !== "dark") next = "light"; |
| 366 | if (next === scheme && colors) return; |
| 367 | scheme = next; |
| 368 | colors = buildColors(next); |
| 369 | colors.whaleNearStyle = rgba(colors.whaleNear, 1); |
| 370 | colors.whaleFarStyle = rgba(colors.whaleFar, 1); |
| 371 | colors.fishStyle = rgba(colors.fish, 1); |
| 372 | colors.bubbleStyle = rgba(colors.bubble, 1); |
| 373 | rebuildGradient(); |
| 374 | } |
| 375 | |
| 376 | function frame(now) { |
| 377 | raf = 0; |
| 378 | if (!running) return; |
| 379 | if (now - lastFrame < FRAME_MS - 1) { raf = requestAnimationFrame(frame); return; } |
| 380 | lastFrame = now; |
| 381 | var t = (now - t0) / 1000; |
| 382 | var dt = Math.min(0.1, t - lastT); |
| 383 | lastT = t; |
| 384 | step(t, dt); |
| 385 | paint(t); |
| 386 | if (!staticOnly) raf = requestAnimationFrame(frame); |
| 387 | } |
| 388 | function step(t, dt) { |
| 389 | whales[0].step(t, dt, W, H); |
| 390 | whales[1].step(t, dt, W, H); |
| 391 | stepFish(fish, t, dt, W, H); |
| 392 | stepBubbles(bubbles, t, dt, W, H); |
| 393 | } |
| 394 | function paint(t) { |
| 395 | g.globalAlpha = 1; |
| 396 | g.fillStyle = gradient; |
| 397 | g.fillRect(0, 0, W, H); |
| 398 | var a = intensity; |
| 399 | g.fillStyle = colors.whaleFarStyle; |
| 400 | whales[0].draw(g, t, a * (colors.dark ? 0.46 : 0.4)); |
| 401 | g.strokeStyle = colors.bubbleStyle; |
| 402 | drawBubbles(g, bubbles, a * (colors.dark ? 0.66 : 0.58)); |
| 403 | whales[0].drawSpout(g, a * 0.6); |
| 404 | whales[1].drawSpout(g, a * 0.6); |
| 405 | g.fillStyle = colors.fishStyle; |
| 406 | drawFish(g, fish, a * (colors.dark ? 0.96 : 0.95)); |
| 407 | g.fillStyle = colors.whaleNearStyle; |
| 408 | whales[1].draw(g, t, a * (colors.dark ? 0.78 : 0.64)); |
| 409 | g.globalAlpha = 1; |
| 410 | } |
| 411 | |
| 412 | function onVisibility() { |
| 413 | if (document.hidden) { if (raf) { cancelAnimationFrame(raf); raf = 0; } } |
| 414 | else if (running && !raf && !staticOnly) { lastFrame = 0; lastT = (performance.now() - t0) / 1000; raf = requestAnimationFrame(frame); } |
| 415 | } |
| 416 | |
| 417 | function mount() { |
| 418 | if (mounted) return true; |
| 419 | if (isOff()) return false; |
| 420 | canvas = document.createElement("canvas"); |
| 421 | canvas.setAttribute("aria-hidden", "true"); |
| 422 | canvas.setAttribute("data-codewhale-ocean", ""); |
| 423 | canvas.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;z-index:-1;pointer-events:none;display:block;"; |
| 424 | g = canvas.getContext("2d", { alpha: false }); |
| 425 | if (!g) return false; |
| 426 | document.body.insertBefore(canvas, document.body.firstChild); |
| 427 | t0 = performance.now(); |
| 428 | applyScheme(detectScheme()); |
| 429 | resize(); |
| 430 | window.addEventListener("resize", resize); |
| 431 | document.addEventListener("visibilitychange", onVisibility); |
| 432 | mounted = true; |
| 433 | return true; |
| 434 | } |
| 435 | |
| 436 | function start() { |
| 437 | if (!document.body) { |
| 438 | document.addEventListener("DOMContentLoaded", function () { start(); }, { once: true }); |
| 439 | return true; |
| 440 | } |
| 441 | if (!mount()) return false; |
| 442 | staticOnly = reducedMotion(); |
| 443 | running = true; |
| 444 | if (staticOnly) { |
| 445 | // one calm frame, then nothing moves: settle the school first so |
| 446 | // the still image reads as a school, not a spawn point |
| 447 | var t = 0; |
| 448 | for (var i = 0; i < 90; i++) { t += 1 / 30; step(t, 1 / 30); } |
| 449 | paint(t); |
| 450 | return true; |
| 451 | } |
| 452 | if (!raf) { lastFrame = 0; lastT = (performance.now() - t0) / 1000; raf = requestAnimationFrame(frame); } |
| 453 | return true; |
| 454 | } |
| 455 | function stop() { |
| 456 | running = false; |
| 457 | if (raf) { cancelAnimationFrame(raf); raf = 0; } |
| 458 | if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); |
| 459 | window.removeEventListener("resize", resize); |
| 460 | document.removeEventListener("visibilitychange", onVisibility); |
| 461 | mounted = false; W = 0; H = 0; gradient = null; |
| 462 | } |
| 463 | function setIntensity(v) { |
| 464 | intensity = Math.max(0, Math.min(1, Number(v) || 0)); |
| 465 | if (staticOnly && running) paint(3); |
| 466 | } |
| 467 | function setScheme(next) { |
| 468 | applyScheme(next); |
| 469 | if (staticOnly && running) paint(3); |
| 470 | } |
| 471 | |
| 472 | var api = { start: start, stop: stop, setIntensity: setIntensity, setScheme: setScheme, isOff: isOff, get running() { return running; } }; |
| 473 | window.__codewhaleOcean = api; |
| 474 | return api; |
| 475 | } |
| 476 |