PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.9
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / about-scene.js

about-scene.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.8.9, at assets/js/about-scene.js

785 lines 25.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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(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