| 1 |
(function() { |
| 2 |
"use strict"; |
| 3 |
const CONFIG = { |
| 4 |
/** Grid stride when sampling the logo PNG. Lower → denser → heavier. */ |
| 5 |
sampleStride: 2, |
| 6 |
/** Alpha threshold (0–255) above which a sampled pixel becomes a particle home. */ |
| 7 |
alphaThreshold: 64, |
| 8 |
/** Cap on total particles — guards against absurd logo sizes. */ |
| 9 |
maxParticles: 8e3, |
| 10 |
/** Particle "home" layout: fraction of the canvas the logo occupies. */ |
| 11 |
logoFraction: 0.78, |
| 12 |
/** |
| 13 |
* Vertical centre of the logo as a fraction of canvas height. Pushed |
| 14 |
* a touch below 0.5 because the title text sits above the logotype |
| 15 |
* — visual centre vs. geometric centre. Tuned so the white space |
| 16 |
* above the eyebrow and below the hint feels balanced. |
| 17 |
*/ |
| 18 |
logoCenterY: 0.55, |
| 19 |
/** Spring stiffness toward the home position. */ |
| 20 |
springK: 0.018, |
| 21 |
/** Velocity damping per tick — lower = more lethargic, more drift. */ |
| 22 |
damping: 0.88, |
| 23 |
/** Velocity floor below which a particle snaps to its home. */ |
| 24 |
restVelocityEpsilon: 0.025, |
| 25 |
/** |
| 26 |
* Cursor magnetic radius (CSS pixels). Particles inside this disc |
| 27 |
* feel an inverse-square pull toward the pointer. |
| 28 |
*/ |
| 29 |
magnetRadius: 220, |
| 30 |
/** Magnetic strength scalar — higher = grabbier, but easy to over-do. */ |
| 31 |
magnetStrength: 1600, |
| 32 |
/** Cap the per-particle magnetic force so deep-radius particles don't teleport. */ |
| 33 |
magnetForceCap: 1.6, |
| 34 |
/** |
| 35 |
* Sand-drag brush radius — particles within this distance of the |
| 36 |
* cursor inherit a fraction of its per-frame displacement, so a |
| 37 |
* fast pan whips them along the cursor's direction of travel |
| 38 |
* (independent of the magnetic pull above). |
| 39 |
*/ |
| 40 |
dragRadius: 140, |
| 41 |
dragStrength: 0.18, |
| 42 |
maxMouseDelta: 80, |
| 43 |
/** Click shockwave: peak outward push at the impact ring. */ |
| 44 |
shockwavePeak: 14, |
| 45 |
/** Speed (CSS px / frame) at which the shockwave ring expands outward. */ |
| 46 |
shockwaveSpeed: 22, |
| 47 |
/** Frames a shockwave lives before it's culled. */ |
| 48 |
shockwaveLifeFrames: 75, |
| 49 |
/** |
| 50 |
* Boids: search radius for neighbours when computing separation / |
| 51 |
* alignment. Tighter than the standard preset because the dense |
| 52 |
* particle field makes a small radius give a beautiful subtle |
| 53 |
* "shimmer" without the loop blowing up O(neighbours). |
| 54 |
*/ |
| 55 |
boidsRadius: 14, |
| 56 |
/** Boids: separation force scalar — pushes apart particles that are too close. */ |
| 57 |
separationStrength: 0.01, |
| 58 |
/** Boids: alignment force scalar — biases velocity toward neighbour mean. */ |
| 59 |
alignmentStrength: 5e-3, |
| 60 |
/** Cell size of the spatial hash used by the boids loop. */ |
| 61 |
gridCell: 18, |
| 62 |
/** Sparkle pool size — borrowed-only, so capping it costs nothing on idle frames. */ |
| 63 |
sparkleCount: 128, |
| 64 |
/** Per-frame chance of spawning a sparkle from any particle. */ |
| 65 |
sparkleSpawnRate: 0.6, |
| 66 |
/** Sparkle frames-of-life. */ |
| 67 |
sparkleLifeFrames: 80, |
| 68 |
/** Particle brush texture pixel size. Big enough to look soft when scaled. */ |
| 69 |
brushSize: 96, |
| 70 |
/** Sparkle brush texture pixel size — smaller = sharper twinkle. */ |
| 71 |
sparkleBrushSize: 32, |
| 72 |
/** |
| 73 |
* Per-particle sprite size range — smaller than the wallpaper variant |
| 74 |
* because the field is much denser; larger sprites would mush the |
| 75 |
* lettering into a glow blob. |
| 76 |
*/ |
| 77 |
spriteScaleMin: 0.05, |
| 78 |
spriteScaleMax: 0.13, |
| 79 |
/** Per-particle alpha range. */ |
| 80 |
spriteAlphaMin: 0.55, |
| 81 |
spriteAlphaMax: 0.95, |
| 82 |
/** Hue cycle period in frames (~7s at 60fps). */ |
| 83 |
hueCycleFrames: 420 |
| 84 |
}; |
| 85 |
const HUE_PALETTE = [ |
| 86 |
5162495, |
| 87 |
// sky cyan (the Automattic blue dot, brightened) |
| 88 |
8031487, |
| 89 |
// periwinkle |
| 90 |
11889663, |
| 91 |
// amethyst |
| 92 |
16735441, |
| 93 |
// electric magenta |
| 94 |
16744043, |
| 95 |
// sunset coral |
| 96 |
16765286, |
| 97 |
// soft gold |
| 98 |
5036472, |
| 99 |
// mint |
| 100 |
5162495 |
| 101 |
// back to start (closes the loop seamlessly) |
| 102 |
]; |
| 103 |
const BACKDROP_CSS = "radial-gradient(circle at 50% 35%, #1b3461 0%, #0c1733 55%, #050918 100%)"; |
| 104 |
async function mountAboutScene(opts) { |
| 105 |
const { container, logoUrl, prefersReducedMotion, labels } = opts; |
| 106 |
const pixi = window.PIXI; |
| 107 |
if (!pixi) { |
| 108 |
throw new Error( |
| 109 |
"[desktop-mode/about] window.PIXI is undefined; load the pixijs module before calling mountAboutScene()." |
| 110 |
); |
| 111 |
} |
| 112 |
const { homes, aspectRatio } = await sampleLogoHomes(logoUrl); |
| 113 |
const priorBackground = container.style.background; |
| 114 |
container.style.background = BACKDROP_CSS; |
| 115 |
const app = new pixi.Application(); |
| 116 |
await app.init({ |
| 117 |
resizeTo: container, |
| 118 |
backgroundAlpha: 0, |
| 119 |
antialias: true, |
| 120 |
autoDensity: true, |
| 121 |
resolution: Math.min(window.devicePixelRatio || 1, 2) |
| 122 |
}); |
| 123 |
container.appendChild(app.canvas); |
| 124 |
applyCanvasLayout(app.canvas); |
| 125 |
const brushTexture = buildBrushTexture(pixi, CONFIG.brushSize); |
| 126 |
const sparkleTexture = buildSparkleTexture(pixi, CONFIG.sparkleBrushSize); |
| 127 |
const particleLayer = new pixi.Container(); |
| 128 |
const sparkleLayer = new pixi.Container(); |
| 129 |
const textLayer = new pixi.Container(); |
| 130 |
app.stage.addChild(particleLayer); |
| 131 |
app.stage.addChild(sparkleLayer); |
| 132 |
app.stage.addChild(textLayer); |
| 133 |
const fontStack = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; |
| 134 |
const eyebrowText = makeText(pixi, labels.eyebrow, { |
| 135 |
fontFamily: fontStack, |
| 136 |
fontSize: 11, |
| 137 |
fill: 10204671, |
| 138 |
fontWeight: "600", |
| 139 |
letterSpacing: 4 |
| 140 |
}); |
| 141 |
const titleText = makeText(pixi, labels.title, { |
| 142 |
fontFamily: fontStack, |
| 143 |
fontSize: 38, |
| 144 |
fill: 16777215, |
| 145 |
fontWeight: "300", |
| 146 |
letterSpacing: -0.4 |
| 147 |
}); |
| 148 |
const bylineText = makeText(pixi, labels.byline, { |
| 149 |
fontFamily: fontStack, |
| 150 |
fontSize: 14, |
| 151 |
fill: 12634858, |
| 152 |
fontWeight: "400", |
| 153 |
fontStyle: "italic", |
| 154 |
letterSpacing: 0.3 |
| 155 |
}); |
| 156 |
const versionText = makeText( |
| 157 |
pixi, |
| 158 |
labels.version, |
| 159 |
{ |
| 160 |
fontFamily: fontStack, |
| 161 |
fontSize: 11, |
| 162 |
fill: 7306928, |
| 163 |
fontWeight: "500", |
| 164 |
letterSpacing: 1 |
| 165 |
} |
| 166 |
); |
| 167 |
const hintText = makeText(pixi, labels.hint, { |
| 168 |
fontFamily: fontStack, |
| 169 |
fontSize: 10, |
| 170 |
fill: 16777215, |
| 171 |
fontWeight: "500", |
| 172 |
letterSpacing: 2.4 |
| 173 |
}); |
| 174 |
hintText.alpha = 0.5; |
| 175 |
textLayer.addChild(eyebrowText); |
| 176 |
textLayer.addChild(titleText); |
| 177 |
textLayer.addChild(bylineText); |
| 178 |
textLayer.addChild(versionText); |
| 179 |
textLayer.addChild(hintText); |
| 180 |
const n = homes.length; |
| 181 |
const homeX = new Float32Array(n); |
| 182 |
const homeY = new Float32Array(n); |
| 183 |
const x = new Float32Array(n); |
| 184 |
const y = new Float32Array(n); |
| 185 |
const vx = new Float32Array(n); |
| 186 |
const vy = new Float32Array(n); |
| 187 |
const phase = new Float32Array(n); |
| 188 |
const sprites = new Array(n); |
| 189 |
for (let i = 0; i < n; i++) { |
| 190 |
const sprite = new pixi.Sprite(brushTexture); |
| 191 |
sprite.anchor.set(0.5); |
| 192 |
sprite.blendMode = "add"; |
| 193 |
const scale = CONFIG.spriteScaleMin + Math.random() * (CONFIG.spriteScaleMax - CONFIG.spriteScaleMin); |
| 194 |
sprite.scale.set(scale); |
| 195 |
sprite.alpha = CONFIG.spriteAlphaMin + Math.random() * (CONFIG.spriteAlphaMax - CONFIG.spriteAlphaMin); |
| 196 |
particleLayer.addChild(sprite); |
| 197 |
sprites[i] = sprite; |
| 198 |
phase[i] = Math.random(); |
| 199 |
} |
| 200 |
const sparkles = []; |
| 201 |
for (let i = 0; i < CONFIG.sparkleCount; i++) { |
| 202 |
const sprite = new pixi.Sprite(sparkleTexture); |
| 203 |
sprite.anchor.set(0.5); |
| 204 |
sprite.blendMode = "add"; |
| 205 |
sprite.visible = false; |
| 206 |
sparkleLayer.addChild(sprite); |
| 207 |
sparkles.push({ sprite, life: 0, vx: 0, vy: 0 }); |
| 208 |
} |
| 209 |
let prevOffsetX = NaN; |
| 210 |
let prevOffsetY = NaN; |
| 211 |
let prevWidthPx = 0; |
| 212 |
let prevHeightPx = 0; |
| 213 |
const computeLayout = () => { |
| 214 |
const w = app.canvas.clientWidth; |
| 215 |
const h = app.canvas.clientHeight; |
| 216 |
if (w <= 0 || h <= 0) { |
| 217 |
return; |
| 218 |
} |
| 219 |
const fitW = w * CONFIG.logoFraction; |
| 220 |
const fitH = h * CONFIG.logoFraction * 0.45; |
| 221 |
let widthPx = fitW; |
| 222 |
let heightPx = widthPx / aspectRatio; |
| 223 |
if (heightPx > fitH) { |
| 224 |
heightPx = fitH; |
| 225 |
widthPx = heightPx * aspectRatio; |
| 226 |
} |
| 227 |
const logoOffsetX = (w - widthPx) / 2; |
| 228 |
const logoOffsetY = h * CONFIG.logoCenterY - heightPx / 2; |
| 229 |
const isFirstCompute = Number.isNaN(prevOffsetX) || prevWidthPx <= 0 || prevHeightPx <= 0; |
| 230 |
if (!isFirstCompute) { |
| 231 |
const sx = widthPx / prevWidthPx; |
| 232 |
const sy = heightPx / prevHeightPx; |
| 233 |
for (let i = 0; i < n; i++) { |
| 234 |
const relX = (x[i] - prevOffsetX) / prevWidthPx; |
| 235 |
const relY = (y[i] - prevOffsetY) / prevHeightPx; |
| 236 |
x[i] = logoOffsetX + relX * widthPx; |
| 237 |
y[i] = logoOffsetY + relY * heightPx; |
| 238 |
vx[i] *= sx; |
| 239 |
vy[i] *= sy; |
| 240 |
} |
| 241 |
} |
| 242 |
for (let i = 0; i < n; i++) { |
| 243 |
homeX[i] = logoOffsetX + homes[i][0] * widthPx; |
| 244 |
homeY[i] = logoOffsetY + homes[i][1] * heightPx; |
| 245 |
if (isFirstCompute) { |
| 246 |
x[i] = homeX[i]; |
| 247 |
y[i] = homeY[i]; |
| 248 |
} |
| 249 |
} |
| 250 |
const cx = w / 2; |
| 251 |
eyebrowText.x = cx; |
| 252 |
eyebrowText.y = Math.max(28, h * 0.1); |
| 253 |
titleText.x = cx; |
| 254 |
titleText.y = Math.max(56, h * 0.18); |
| 255 |
const titleScale = clamp(w / 760, 0.6, 1.25); |
| 256 |
titleText.scale.set(titleScale); |
| 257 |
const logoBottom = logoOffsetY + heightPx; |
| 258 |
bylineText.x = cx; |
| 259 |
bylineText.y = Math.min(h - 70, logoBottom + 40); |
| 260 |
versionText.x = cx; |
| 261 |
versionText.y = Math.min(h - 46, logoBottom + 70); |
| 262 |
hintText.x = cx; |
| 263 |
hintText.y = h - 22; |
| 264 |
prevOffsetX = logoOffsetX; |
| 265 |
prevOffsetY = logoOffsetY; |
| 266 |
prevWidthPx = widthPx; |
| 267 |
prevHeightPx = heightPx; |
| 268 |
}; |
| 269 |
computeLayout(); |
| 270 |
const resizeObserver = new ResizeObserver(() => { |
| 271 |
const w = container.clientWidth; |
| 272 |
const h = container.clientHeight; |
| 273 |
if (w > 0 && h > 0) { |
| 274 |
app.renderer.resize(w, h); |
| 275 |
} |
| 276 |
computeLayout(); |
| 277 |
try { |
| 278 |
app.render(); |
| 279 |
} catch { |
| 280 |
} |
| 281 |
}); |
| 282 |
resizeObserver.observe(container); |
| 283 |
let pointerX = -1e6; |
| 284 |
let pointerY = -1e6; |
| 285 |
let pointerActive = false; |
| 286 |
let mouseDx = 0; |
| 287 |
let mouseDy = 0; |
| 288 |
const shockwaves = []; |
| 289 |
const onPointerMove = (e) => { |
| 290 |
const rect = app.canvas.getBoundingClientRect(); |
| 291 |
const nx = e.clientX - rect.left; |
| 292 |
const ny = e.clientY - rect.top; |
| 293 |
const inside = nx >= 0 && ny >= 0 && nx <= rect.width && ny <= rect.height; |
| 294 |
if (!inside) { |
| 295 |
pointerActive = false; |
| 296 |
pointerX = -1e6; |
| 297 |
pointerY = -1e6; |
| 298 |
return; |
| 299 |
} |
| 300 |
if (pointerActive) { |
| 301 |
const cap = CONFIG.maxMouseDelta; |
| 302 |
mouseDx += Math.max(-cap, Math.min(cap, nx - pointerX)); |
| 303 |
mouseDy += Math.max(-cap, Math.min(cap, ny - pointerY)); |
| 304 |
} |
| 305 |
pointerX = nx; |
| 306 |
pointerY = ny; |
| 307 |
pointerActive = true; |
| 308 |
}; |
| 309 |
const onPointerLeave = () => { |
| 310 |
pointerActive = false; |
| 311 |
pointerX = -1e6; |
| 312 |
pointerY = -1e6; |
| 313 |
mouseDx = 0; |
| 314 |
mouseDy = 0; |
| 315 |
}; |
| 316 |
const onPointerDown = (e) => { |
| 317 |
const rect = app.canvas.getBoundingClientRect(); |
| 318 |
const nx = e.clientX - rect.left; |
| 319 |
const ny = e.clientY - rect.top; |
| 320 |
if (nx < 0 || ny < 0 || nx > rect.width || ny > rect.height) { |
| 321 |
return; |
| 322 |
} |
| 323 |
shockwaves.push({ x: nx, y: ny, age: 0 }); |
| 324 |
}; |
| 325 |
app.canvas.addEventListener("pointermove", onPointerMove, { passive: true }); |
| 326 |
app.canvas.addEventListener("pointerleave", onPointerLeave); |
| 327 |
app.canvas.addEventListener("pointerdown", onPointerDown); |
| 328 |
let animating = !prefersReducedMotion; |
| 329 |
let frame = 0; |
| 330 |
const syncSprites = () => { |
| 331 |
const cycle = frame % CONFIG.hueCycleFrames / CONFIG.hueCycleFrames; |
| 332 |
for (let i = 0; i < n; i++) { |
| 333 |
const sprite = sprites[i]; |
| 334 |
sprite.x = x[i]; |
| 335 |
sprite.y = y[i]; |
| 336 |
const t = (cycle + phase[i]) % 1; |
| 337 |
const idxF = t * (HUE_PALETTE.length - 1); |
| 338 |
const a = HUE_PALETTE[Math.floor(idxF)]; |
| 339 |
const b = HUE_PALETTE[Math.min(HUE_PALETTE.length - 1, Math.floor(idxF) + 1)]; |
| 340 |
sprite.tint = mixColor(a, b, idxF - Math.floor(idxF)); |
| 341 |
} |
| 342 |
}; |
| 343 |
const tick = () => { |
| 344 |
frame++; |
| 345 |
if (animating) { |
| 346 |
step( |
| 347 |
n, |
| 348 |
homeX, |
| 349 |
homeY, |
| 350 |
x, |
| 351 |
y, |
| 352 |
vx, |
| 353 |
vy, |
| 354 |
pointerX, |
| 355 |
pointerY, |
| 356 |
pointerActive, |
| 357 |
pointerActive ? mouseDx : 0, |
| 358 |
pointerActive ? mouseDy : 0, |
| 359 |
shockwaves |
| 360 |
); |
| 361 |
updateShockwaves(shockwaves); |
| 362 |
updateSparkles(sparkles, x, y, n); |
| 363 |
} |
| 364 |
mouseDx = 0; |
| 365 |
mouseDy = 0; |
| 366 |
syncSprites(); |
| 367 |
}; |
| 368 |
app.ticker.add(tick); |
| 369 |
syncSprites(); |
| 370 |
if (!animating) { |
| 371 |
app.renderer.render(app.stage); |
| 372 |
app.ticker.stop(); |
| 373 |
} |
| 374 |
return { |
| 375 |
destroy() { |
| 376 |
resizeObserver.disconnect(); |
| 377 |
app.canvas.removeEventListener("pointermove", onPointerMove); |
| 378 |
app.canvas.removeEventListener("pointerleave", onPointerLeave); |
| 379 |
app.canvas.removeEventListener("pointerdown", onPointerDown); |
| 380 |
try { |
| 381 |
app.destroy({ removeView: true }, { |
| 382 |
children: true, |
| 383 |
texture: true, |
| 384 |
textureSource: true, |
| 385 |
context: true |
| 386 |
}); |
| 387 |
} catch { |
| 388 |
} |
| 389 |
try { |
| 390 |
brushTexture.destroy(true); |
| 391 |
} catch { |
| 392 |
} |
| 393 |
try { |
| 394 |
sparkleTexture.destroy(true); |
| 395 |
} catch { |
| 396 |
} |
| 397 |
container.style.background = priorBackground; |
| 398 |
}, |
| 399 |
setAnimating(playing) { |
| 400 |
animating = playing && !prefersReducedMotion; |
| 401 |
if (animating) { |
| 402 |
app.ticker.start(); |
| 403 |
} else { |
| 404 |
app.ticker.stop(); |
| 405 |
} |
| 406 |
} |
| 407 |
}; |
| 408 |
} |
| 409 |
function step(n, homeX, homeY, x, y, vx, vy, pointerX, pointerY, pointerActive, mouseDx, mouseDy, shockwaves) { |
| 410 |
const { |
| 411 |
springK, |
| 412 |
damping, |
| 413 |
magnetRadius, |
| 414 |
magnetStrength, |
| 415 |
magnetForceCap, |
| 416 |
dragRadius, |
| 417 |
dragStrength, |
| 418 |
shockwavePeak, |
| 419 |
shockwaveSpeed, |
| 420 |
shockwaveLifeFrames, |
| 421 |
boidsRadius, |
| 422 |
separationStrength, |
| 423 |
alignmentStrength, |
| 424 |
gridCell, |
| 425 |
restVelocityEpsilon |
| 426 |
} = CONFIG; |
| 427 |
const magnetRadSq = magnetRadius * magnetRadius; |
| 428 |
const dragRadSq = dragRadius * dragRadius; |
| 429 |
const restEpsSq = restVelocityEpsilon * restVelocityEpsilon; |
| 430 |
const grid = boidsBuildGrid(n, x, y, gridCell); |
| 431 |
const mouseSpeed = Math.sqrt(mouseDx * mouseDx + mouseDy * mouseDy); |
| 432 |
const cursorMoving = mouseSpeed > 1e-3; |
| 433 |
const dragFx = mouseDx * dragStrength; |
| 434 |
const dragFy = mouseDy * dragStrength; |
| 435 |
for (let i = 0; i < n; i++) { |
| 436 |
const dhx = homeX[i] - x[i]; |
| 437 |
const dhy = homeY[i] - y[i]; |
| 438 |
let fx = dhx * springK; |
| 439 |
let fy = dhy * springK; |
| 440 |
if (pointerActive) { |
| 441 |
const dx = pointerX - x[i]; |
| 442 |
const dy = pointerY - y[i]; |
| 443 |
const distSq = dx * dx + dy * dy; |
| 444 |
if (distSq < magnetRadSq && distSq > 4) { |
| 445 |
const dist = Math.sqrt(distSq); |
| 446 |
let force = magnetStrength / distSq; |
| 447 |
if (force > magnetForceCap) { |
| 448 |
force = magnetForceCap; |
| 449 |
} |
| 450 |
fx += dx / dist * force; |
| 451 |
fy += dy / dist * force; |
| 452 |
} |
| 453 |
} |
| 454 |
if (cursorMoving) { |
| 455 |
const dx = x[i] - pointerX; |
| 456 |
const dy = y[i] - pointerY; |
| 457 |
const distSq = dx * dx + dy * dy; |
| 458 |
if (distSq < dragRadSq) { |
| 459 |
const t = 1 - Math.sqrt(distSq) / dragRadius; |
| 460 |
const falloff = t * t; |
| 461 |
fx += dragFx * falloff; |
| 462 |
fy += dragFy * falloff; |
| 463 |
} |
| 464 |
} |
| 465 |
for (let s = 0; s < shockwaves.length; s++) { |
| 466 |
const sw = shockwaves[s]; |
| 467 |
const dx = x[i] - sw.x; |
| 468 |
const dy = y[i] - sw.y; |
| 469 |
const dist = Math.sqrt(dx * dx + dy * dy); |
| 470 |
const ringR = sw.age * shockwaveSpeed; |
| 471 |
const lifeT = sw.age / shockwaveLifeFrames; |
| 472 |
const lifeFalloff = (1 - lifeT) * (1 - lifeT); |
| 473 |
const ringWidth = 30 + sw.age * 0.5; |
| 474 |
const ringDelta = Math.abs(dist - ringR); |
| 475 |
if (ringDelta < ringWidth && dist > 0.01) { |
| 476 |
const ringStrength = 1 - ringDelta / ringWidth; |
| 477 |
const force = shockwavePeak * ringStrength * lifeFalloff; |
| 478 |
fx += dx / dist * force; |
| 479 |
fy += dy / dist * force; |
| 480 |
} |
| 481 |
} |
| 482 |
const cx = Math.floor(x[i] / gridCell); |
| 483 |
const cy = Math.floor(y[i] / gridCell); |
| 484 |
let sepFx = 0; |
| 485 |
let sepFy = 0; |
| 486 |
let alignVx = 0; |
| 487 |
let alignVy = 0; |
| 488 |
let neighbourCount = 0; |
| 489 |
const radSq = boidsRadius * boidsRadius; |
| 490 |
for (let nx = cx - 1; nx <= cx + 1; nx++) { |
| 491 |
for (let ny = cy - 1; ny <= cy + 1; ny++) { |
| 492 |
const bucket = grid.get(nx * 100003 + ny); |
| 493 |
if (!bucket) { |
| 494 |
continue; |
| 495 |
} |
| 496 |
for (let k = 0; k < bucket.length; k++) { |
| 497 |
const j = bucket[k]; |
| 498 |
if (j === i) { |
| 499 |
continue; |
| 500 |
} |
| 501 |
const ddx = x[i] - x[j]; |
| 502 |
const ddy = y[i] - y[j]; |
| 503 |
const dSq = ddx * ddx + ddy * ddy; |
| 504 |
if (dSq < radSq && dSq > 0.01) { |
| 505 |
const inv = 1 / dSq; |
| 506 |
sepFx += ddx * inv; |
| 507 |
sepFy += ddy * inv; |
| 508 |
alignVx += vx[j]; |
| 509 |
alignVy += vy[j]; |
| 510 |
neighbourCount++; |
| 511 |
} |
| 512 |
} |
| 513 |
} |
| 514 |
} |
| 515 |
if (neighbourCount > 0) { |
| 516 |
fx += sepFx * separationStrength; |
| 517 |
fy += sepFy * separationStrength; |
| 518 |
fx += (alignVx / neighbourCount - vx[i]) * alignmentStrength; |
| 519 |
fy += (alignVy / neighbourCount - vy[i]) * alignmentStrength; |
| 520 |
} |
| 521 |
const nvx = (vx[i] + fx) * damping; |
| 522 |
const nvy = (vy[i] + fy) * damping; |
| 523 |
const calm = !pointerActive && shockwaves.length === 0 && nvx * nvx + nvy * nvy < restEpsSq && dhx * dhx + dhy * dhy < 0.5; |
| 524 |
if (calm) { |
| 525 |
x[i] = homeX[i]; |
| 526 |
y[i] = homeY[i]; |
| 527 |
vx[i] = 0; |
| 528 |
vy[i] = 0; |
| 529 |
continue; |
| 530 |
} |
| 531 |
vx[i] = nvx; |
| 532 |
vy[i] = nvy; |
| 533 |
x[i] += nvx; |
| 534 |
y[i] += nvy; |
| 535 |
} |
| 536 |
} |
| 537 |
function boidsBuildGrid(n, x, y, gridCell) { |
| 538 |
const grid = /* @__PURE__ */ new Map(); |
| 539 |
for (let i = 0; i < n; i++) { |
| 540 |
const cx = Math.floor(x[i] / gridCell); |
| 541 |
const cy = Math.floor(y[i] / gridCell); |
| 542 |
const key = cx * 100003 + cy; |
| 543 |
const bucket = grid.get(key); |
| 544 |
if (bucket) { |
| 545 |
bucket.push(i); |
| 546 |
} else { |
| 547 |
grid.set(key, [i]); |
| 548 |
} |
| 549 |
} |
| 550 |
return grid; |
| 551 |
} |
| 552 |
function updateShockwaves(shockwaves) { |
| 553 |
for (let i = shockwaves.length - 1; i >= 0; i--) { |
| 554 |
shockwaves[i].age++; |
| 555 |
if (shockwaves[i].age >= CONFIG.shockwaveLifeFrames) { |
| 556 |
shockwaves.splice(i, 1); |
| 557 |
} |
| 558 |
} |
| 559 |
} |
| 560 |
function updateSparkles(sparkles, x, y, n) { |
| 561 |
let spawnsLeft = 0; |
| 562 |
let r = Math.random(); |
| 563 |
while (r < CONFIG.sparkleSpawnRate) { |
| 564 |
spawnsLeft++; |
| 565 |
r += Math.random(); |
| 566 |
} |
| 567 |
for (let s = 0; s < sparkles.length && spawnsLeft > 0; s++) { |
| 568 |
if (sparkles[s].life <= 0) { |
| 569 |
const idx = Math.floor(Math.random() * n); |
| 570 |
const sprite = sparkles[s].sprite; |
| 571 |
sprite.x = x[idx]; |
| 572 |
sprite.y = y[idx]; |
| 573 |
sprite.scale.set(0.4 + Math.random() * 0.5); |
| 574 |
sprite.alpha = 1; |
| 575 |
sprite.tint = HUE_PALETTE[Math.floor(Math.random() * HUE_PALETTE.length)]; |
| 576 |
sprite.visible = true; |
| 577 |
sparkles[s].life = CONFIG.sparkleLifeFrames; |
| 578 |
sparkles[s].vx = (Math.random() - 0.5) * 0.3; |
| 579 |
sparkles[s].vy = -0.45 - Math.random() * 0.4; |
| 580 |
spawnsLeft--; |
| 581 |
} |
| 582 |
} |
| 583 |
for (let s = 0; s < sparkles.length; s++) { |
| 584 |
const spk = sparkles[s]; |
| 585 |
if (spk.life <= 0) { |
| 586 |
continue; |
| 587 |
} |
| 588 |
spk.life--; |
| 589 |
spk.sprite.x += spk.vx; |
| 590 |
spk.sprite.y += spk.vy; |
| 591 |
const t = spk.life / CONFIG.sparkleLifeFrames; |
| 592 |
spk.sprite.alpha = t * t; |
| 593 |
const baseScale = 0.4 + (1 - t) * 0.4; |
| 594 |
spk.sprite.scale.set(baseScale); |
| 595 |
if (spk.life <= 0) { |
| 596 |
spk.sprite.visible = false; |
| 597 |
} |
| 598 |
} |
| 599 |
} |
| 600 |
function mixColor(a, b, t) { |
| 601 |
const ar = Math.floor(a / 65536); |
| 602 |
const ag = Math.floor(a / 256) % 256; |
| 603 |
const ab = a % 256; |
| 604 |
const br = Math.floor(b / 65536); |
| 605 |
const bg = Math.floor(b / 256) % 256; |
| 606 |
const bb = b % 256; |
| 607 |
const r = Math.round(ar + (br - ar) * t); |
| 608 |
const g = Math.round(ag + (bg - ag) * t); |
| 609 |
const bcomp = Math.round(ab + (bb - ab) * t); |
| 610 |
return r * 65536 + g * 256 + bcomp; |
| 611 |
} |
| 612 |
function buildBrushTexture(pixi, size) { |
| 613 |
const canvas = document.createElement("canvas"); |
| 614 |
canvas.width = size; |
| 615 |
canvas.height = size; |
| 616 |
const ctx = canvas.getContext("2d"); |
| 617 |
if (!ctx) { |
| 618 |
throw new Error("[desktop-mode/about] 2D canvas context unavailable."); |
| 619 |
} |
| 620 |
const center = size / 2; |
| 621 |
const gradient = ctx.createRadialGradient( |
| 622 |
center, |
| 623 |
center, |
| 624 |
0, |
| 625 |
center, |
| 626 |
center, |
| 627 |
center |
| 628 |
); |
| 629 |
gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); |
| 630 |
gradient.addColorStop(0.18, "rgba(255, 255, 255, 0.85)"); |
| 631 |
gradient.addColorStop(0.42, "rgba(255, 255, 255, 0.28)"); |
| 632 |
gradient.addColorStop(0.75, "rgba(255, 255, 255, 0.06)"); |
| 633 |
gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); |
| 634 |
ctx.fillStyle = gradient; |
| 635 |
ctx.fillRect(0, 0, size, size); |
| 636 |
return pixi.Texture.from(canvas); |
| 637 |
} |
| 638 |
function buildSparkleTexture(pixi, size) { |
| 639 |
const canvas = document.createElement("canvas"); |
| 640 |
canvas.width = size; |
| 641 |
canvas.height = size; |
| 642 |
const ctx = canvas.getContext("2d"); |
| 643 |
if (!ctx) { |
| 644 |
throw new Error("[desktop-mode/about] 2D canvas context unavailable."); |
| 645 |
} |
| 646 |
const center = size / 2; |
| 647 |
const radial = ctx.createRadialGradient( |
| 648 |
center, |
| 649 |
center, |
| 650 |
0, |
| 651 |
center, |
| 652 |
center, |
| 653 |
center * 0.5 |
| 654 |
); |
| 655 |
radial.addColorStop(0, "rgba(255, 255, 255, 1)"); |
| 656 |
radial.addColorStop(0.4, "rgba(255, 255, 255, 0.5)"); |
| 657 |
radial.addColorStop(1, "rgba(255, 255, 255, 0)"); |
| 658 |
ctx.fillStyle = radial; |
| 659 |
ctx.fillRect(0, 0, size, size); |
| 660 |
ctx.globalCompositeOperation = "lighter"; |
| 661 |
const armWidth = 1.5; |
| 662 |
for (const isVertical of [false, true]) { |
| 663 |
const grad = isVertical ? ctx.createLinearGradient(0, 0, 0, size) : ctx.createLinearGradient(0, 0, size, 0); |
| 664 |
grad.addColorStop(0, "rgba(255, 255, 255, 0)"); |
| 665 |
grad.addColorStop(0.5, "rgba(255, 255, 255, 0.85)"); |
| 666 |
grad.addColorStop(1, "rgba(255, 255, 255, 0)"); |
| 667 |
ctx.fillStyle = grad; |
| 668 |
if (isVertical) { |
| 669 |
ctx.fillRect(center - armWidth, 0, armWidth * 2, size); |
| 670 |
} else { |
| 671 |
ctx.fillRect(0, center - armWidth, size, armWidth * 2); |
| 672 |
} |
| 673 |
} |
| 674 |
return pixi.Texture.from(canvas); |
| 675 |
} |
| 676 |
async function sampleLogoHomes(url) { |
| 677 |
const img = await loadImage(url); |
| 678 |
const maxSide = 600; |
| 679 |
const ratio = img.naturalWidth / img.naturalHeight; |
| 680 |
const sampleWidth = ratio >= 1 ? maxSide : Math.round(maxSide * ratio); |
| 681 |
const sampleHeight = ratio >= 1 ? Math.round(maxSide / ratio) : maxSide; |
| 682 |
const empty = { homes: [], aspectRatio: 1 }; |
| 683 |
const off = document.createElement("canvas"); |
| 684 |
off.width = sampleWidth; |
| 685 |
off.height = sampleHeight; |
| 686 |
const ctx = off.getContext("2d", { willReadFrequently: true }); |
| 687 |
if (!ctx) { |
| 688 |
return empty; |
| 689 |
} |
| 690 |
ctx.drawImage(img, 0, 0, sampleWidth, sampleHeight); |
| 691 |
const data = ctx.getImageData(0, 0, sampleWidth, sampleHeight).data; |
| 692 |
let minX = sampleWidth; |
| 693 |
let minY = sampleHeight; |
| 694 |
let maxX = 0; |
| 695 |
let maxY = 0; |
| 696 |
const threshold = CONFIG.alphaThreshold; |
| 697 |
for (let py = 0; py < sampleHeight; py++) { |
| 698 |
for (let px = 0; px < sampleWidth; px++) { |
| 699 |
const alpha = data[(py * sampleWidth + px) * 4 + 3]; |
| 700 |
if (alpha > threshold) { |
| 701 |
if (px < minX) { |
| 702 |
minX = px; |
| 703 |
} |
| 704 |
if (px > maxX) { |
| 705 |
maxX = px; |
| 706 |
} |
| 707 |
if (py < minY) { |
| 708 |
minY = py; |
| 709 |
} |
| 710 |
if (py > maxY) { |
| 711 |
maxY = py; |
| 712 |
} |
| 713 |
} |
| 714 |
} |
| 715 |
} |
| 716 |
if (minX > maxX || minY > maxY) { |
| 717 |
return empty; |
| 718 |
} |
| 719 |
const bboxW = maxX - minX + 1; |
| 720 |
const bboxH = maxY - minY + 1; |
| 721 |
const aspectRatio = bboxW / bboxH; |
| 722 |
const homes = []; |
| 723 |
const stride = CONFIG.sampleStride; |
| 724 |
for (let row = minY; row <= maxY; row += stride) { |
| 725 |
const rowOffset = (row - minY) / stride % 2 === 0 ? 0 : stride / 2; |
| 726 |
for (let col = minX; col <= maxX; col += stride) { |
| 727 |
const px = Math.min(maxX, Math.round(col + rowOffset)); |
| 728 |
const py = row; |
| 729 |
const alpha = data[(py * sampleWidth + px) * 4 + 3]; |
| 730 |
if (alpha > threshold) { |
| 731 |
homes.push([ |
| 732 |
(px - minX) / bboxW, |
| 733 |
(py - minY) / bboxH |
| 734 |
]); |
| 735 |
if (homes.length >= CONFIG.maxParticles) { |
| 736 |
return { homes, aspectRatio }; |
| 737 |
} |
| 738 |
} |
| 739 |
} |
| 740 |
} |
| 741 |
return { homes, aspectRatio }; |
| 742 |
} |
| 743 |
function loadImage(url) { |
| 744 |
return new Promise((resolve, reject) => { |
| 745 |
const img = new Image(); |
| 746 |
img.crossOrigin = "anonymous"; |
| 747 |
img.onload = () => resolve(img); |
| 748 |
img.onerror = () => reject(new Error(`Failed to load logo: ${url}`)); |
| 749 |
img.src = url; |
| 750 |
}); |
| 751 |
} |
| 752 |
function applyCanvasLayout(canvas) { |
| 753 |
canvas.style.display = "block"; |
| 754 |
canvas.style.width = "100%"; |
| 755 |
canvas.style.height = "100%"; |
| 756 |
} |
| 757 |
function makeText(pixi, text, style) { |
| 758 |
const t = new pixi.Text({ |
| 759 |
text, |
| 760 |
style: { |
| 761 |
fontFamily: style.fontFamily, |
| 762 |
fontSize: style.fontSize, |
| 763 |
fill: style.fill, |
| 764 |
fontWeight: style.fontWeight ?? "normal", |
| 765 |
fontStyle: style.fontStyle ?? "normal", |
| 766 |
letterSpacing: style.letterSpacing ?? 0, |
| 767 |
align: "center" |
| 768 |
}, |
| 769 |
resolution: 2, |
| 770 |
anchor: { x: 0.5, y: 0.5 } |
| 771 |
}); |
| 772 |
return t; |
| 773 |
} |
| 774 |
function clamp(v, lo, hi) { |
| 775 |
if (v < lo) { |
| 776 |
return lo; |
| 777 |
} |
| 778 |
if (v > hi) { |
| 779 |
return hi; |
| 780 |
} |
| 781 |
return v; |
| 782 |
} |
| 783 |
window.desktopModeMountAboutScene = mountAboutScene; |
| 784 |
})(); |
| 785 |
|