1---
2/**
3 * Animated mandala background for the hero — endless lotus zoom
4 * emanating from the Yantra's bindu (center dot).
5 *
6 * The vanishing point is measured at runtime from the element matched
7 * by `center` (default `.alc-hero__mark`), so the animation truly
8 * radiates from the brand mark's dot rather than the canvas geometric
9 * center. The same coordinates are pushed back to CSS as `--alc-bg-cx`
10 * / `--alc-bg-cy` so the mask feather follows.
11 *
12 * Multiple stylistic variants live in this file. The active one is
13 * picked by the `?variant=N` query string (default 1) so we can A/B
14 * compare on the live page without editing the MDX.
15 *
16 * Only variant 1 is exposed: concentric rings + radial vesica arcs
17 * blooming back toward the bindu. The trippy/subtle baseline.
18 *
19 * Variant 2 is kept in this file as a backup (Sri-Yantra-style lotus
20 * petals from the canonical TikZ construction) but is intentionally
21 * not toggleable via `?variant=` — flip the dispatch in `draw()`
22 * manually to revisit it.
23 */
24
25interface Props {
26 /** CSS selector for the element whose center the animation emanates from. */
27 center?: string;
28 /** Stroke color. */
29 color?: string;
30 /** Multiplier on stroke alpha (0..1). */
31 intensity?: number;
32 /** Number of concurrent layers. */
33 count?: number;
34 /** Zoom rate. ~0.06 = one octave of growth per ~16s. */
35 zoom?: number;
36 /** Default variant number when no `?variant=` query is present.
37 * Only `1` is exposed in the public API; variant 2 is kept in the
38 * file as a backup but isn't toggleable from a query string. */
39 variant?: 1;
40}
41
42const {
43 center = ".alc-hero__mark",
44 color = "var(--alc-fg-1)",
45 intensity = 1,
46 count = 10,
47 zoom = 0.025,
48 variant = 1,
49} = Astro.props;
50---
51
52<canvas
53 class="alc-hero__bg-graph"
54 data-variant={variant}
55 data-center={center}
56 data-intensity={intensity}
57 data-count={count}
58 data-zoom={zoom}
59 style={`color: ${color};`}
60 aria-hidden="true"
61></canvas>
62
63<script>
64 /* eslint-disable */
65
66 const init = (canvas: HTMLCanvasElement) => {
67 const ctx = canvas.getContext("2d", { alpha: true });
68 if (!ctx) return;
69
70 const variant: number = Number(canvas.dataset.variant ?? 1);
71
72 const centerSel = canvas.dataset.center ?? ".alc-hero__mark";
73 const intensity = Number(canvas.dataset.intensity ?? 1);
74 const nLayers = Math.max(1, Number(canvas.dataset.count ?? 10));
75 const zoom = Number(canvas.dataset.zoom ?? 0.06);
76 const motionMq = window.matchMedia("(prefers-reduced-motion: reduce)");
77 let reduced = motionMq.matches;
78
79 let dpr = Math.min(window.devicePixelRatio || 1, 2);
80 let width = 0;
81 let height = 0;
82 /** Vanishing point in canvas-local CSS pixels. Updated by `resize`. */
83 let cx0 = 0;
84 let cy0 = 0;
85
86 const centerEl =
87 (centerSel && (document.querySelector(centerSel) as HTMLElement | null)) ||
88 null;
89
90 const measureCenter = () => {
91 if (centerEl) {
92 const heroRect = canvas.getBoundingClientRect();
93 const markRect = centerEl.getBoundingClientRect();
94 cx0 = markRect.left + markRect.width / 2 - heroRect.left;
95 cy0 = markRect.top + markRect.height / 2 - heroRect.top;
96 canvas.style.setProperty("--alc-bg-cx", `${cx0.toFixed(2)}px`);
97 canvas.style.setProperty("--alc-bg-cy", `${cy0.toFixed(2)}px`);
98 } else {
99 cx0 = width / 2;
100 cy0 = height / 2;
101 }
102 };
103
104 const resize = (): boolean => {
105 const rect = canvas.getBoundingClientRect();
106 const newW = rect.width;
107 const newH = rect.height;
108 const newDpr = Math.min(window.devicePixelRatio || 1, 2);
109 // iOS Safari fires `resize` continuously as the URL bar collapses
110 // / expands during scroll. Reassigning `canvas.width`/`height`
111 // wipes the bitmap *even when set to the same value*, which
112 // shows up as the mandala flashing every time you swipe. Skip
113 // when nothing actually changed; only the centerline (handled
114 // separately via ResizeObserver) needs to update on scroll.
115 if (newW === width && newH === height && newDpr === dpr) {
116 return false;
117 }
118 width = newW;
119 height = newH;
120 dpr = newDpr;
121 canvas.width = Math.round(width * dpr);
122 canvas.height = Math.round(height * dpr);
123 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
124 measureCenter();
125 return true;
126 };
127
128 const resolveRGB = (): [number, number, number] => {
129 const rgb = getComputedStyle(canvas).color;
130 const m = rgb.match(/\d+(\.\d+)?/g);
131 if (!m) return [0, 0, 0];
132 return [Number(m[0]), Number(m[1]), Number(m[2])];
133 };
134
135 let rgb = resolveRGB();
136 const rgba = (a: number) =>
137 `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a.toFixed(3)})`;
138
139 /** Tiny bindu marker — drawn for every variant so the dot visually
140 * sources the emanation. */
141 const drawBindu = () => {
142 ctx.fillStyle = rgba(0.55 * intensity);
143 ctx.beginPath();
144 ctx.arc(cx0, cy0, 1.8, 0, Math.PI * 2);
145 ctx.fill();
146 };
147
148 // ============================================================
149 // Variant 1 — concentric rings + radial vesica arcs (original)
150 // ============================================================
151
152 /** Petal count per ring index. Grows outward so outer rings bloom
153 * into more detail while inner rings stay restful. */
154 const petalsAtRing = (i: number) => {
155 const seq = [6, 8, 12, 16, 18, 20, 24, 28, 32, 36, 40];
156 return seq[Math.min(i, seq.length - 1)];
157 };
158
159 const drawV1 = (t: number) => {
160 const minR = Math.max(width, height) * 0.02;
161 const maxR = Math.max(width, height) * 0.95;
162 const logRange = Math.log(maxR / minR);
163 const clock = ((t * 0.001 * zoom) % 1 + 1) % 1;
164 const tSec = t * 0.001;
165
166 drawBindu();
167
168 ctx.lineWidth = 1;
169 const cap = 0.32 * intensity;
170
171 for (let i = 0; i < nLayers; i++) {
172 const phase = (i / nLayers + clock) % 1;
173 const r = minR * Math.exp(phase * logRange);
174 const fade = Math.pow(Math.sin(phase * Math.PI), 1.2);
175 const a = cap * fade;
176 if (a < 0.01) continue;
177
178 // Very slow uniform drift — adjacent rings counter-rotate,
179 // but at the same gentle rate (no per-ring parallax speed-up).
180 // ~1 revolution per ~3 minutes.
181 const spin = tSec * 0.035 * (i % 2 === 0 ? 1 : -1);
182
183 // Concentric ring.
184 ctx.strokeStyle = rgba(a * 0.85);
185 ctx.beginPath();
186 ctx.arc(cx0, cy0, r, 0, Math.PI * 2);
187 ctx.stroke();
188
189 // Radial petal arcs — small arcs centered on points around the
190 // ring, drawing a vesica-like bloom. Arc radius scales with
191 // the ring radius so they read as part of the same petal.
192 const k = petalsAtRing(i);
193 const petalR = r * 0.35;
194 const petalSweep = Math.PI * 0.55;
195 ctx.strokeStyle = rgba(a * 0.65);
196 for (let p = 0; p < k; p++) {
197 const ang = spin + (p * 2 * Math.PI) / k;
198 const px = cx0 + Math.cos(ang) * r;
199 const py = cy0 + Math.sin(ang) * r;
200 // Arc faces back toward the bindu so each petal forms a leaf
201 // pointing outward.
202 ctx.beginPath();
203 ctx.arc(
204 px,
205 py,
206 petalR,
207 ang + Math.PI - petalSweep / 2,
208 ang + Math.PI + petalSweep / 2,
209 );
210 ctx.stroke();
211 }
212 }
213 };
214
215 // ============================================================
216 // Variant 2 (backup, not query-string toggleable) — Sri Yantra
217 // lotus rim built from the canonical TikZ construction
218 // (bhaskatripathi/SriYantra). Two cubic Beziers per side + an
219 // arc base on the inner circle, so the petal silhouette has a
220 // smooth scalloped base and a single sharp tip on the outer
221 // circle. Petal-band aspect (r1/r0 = 1.2) is decoupled from the
222 // inter-layer log spacing.
223 // ============================================================
224
225 /** Stroke a single canonical lotus petal — the TikZ Sri Yantra
226 * construction.
227 *
228 * The petal occupies angular span `[theta - halfAngle, theta +
229 * halfAngle]` on the inner circle (radius `r0`). Its tip sits on
230 * the outer circle (radius `r1`) at the petal's center angle
231 * `theta`. Two cubic Beziers form the upward + downward sides;
232 * the base is a circular arc along `r0` between the two anchor
233 * points, so neighbors share an arc segment (not a single point)
234 * — that's what gives the silhouette a smooth, rounded base
235 * rather than the spiky triangle we had before.
236 *
237 * Control radii / angular offsets follow the proportions used in
238 * the bhaskatripathi/SriYantra LaTeX source (8-petal: anchors
239 * 45° apart, controls at ±5° / ±17.5° at radii 0.7·len /
240 * 0.5·len above the inner circle). Generalized here to any
241 * petal count. */
242 const strokeLotusPetal = (
243 theta: number,
244 r0: number,
245 r1: number,
246 halfAngle: number,
247 ) => {
248 const len = r1 - r0;
249
250 // Anchor points where the petal meets the inner circle.
251 const startAng = theta + halfAngle;
252 const endAng = theta - halfAngle;
253 const sx = cx0 + Math.cos(startAng) * r0;
254 const sy = cy0 + Math.sin(startAng) * r0;
255 const ex = cx0 + Math.cos(endAng) * r0;
256 const ey = cy0 + Math.sin(endAng) * r0;
257
258 // Tip on the outer circle.
259 const tx = cx0 + Math.cos(theta) * r1;
260 const ty = cy0 + Math.sin(theta) * r1;
261
262 // First cubic: from start anchor up to the tip. Two controls
263 // bow the curve up + sideways before pinching back to the tip.
264 const c1Ang = theta + halfAngle * (7 / 9);
265 const c1R = r0 + 0.7 * len;
266 const c1x = cx0 + Math.cos(c1Ang) * c1R;
267 const c1y = cy0 + Math.sin(c1Ang) * c1R;
268
269 const c2Ang = theta + halfAngle * (2 / 9);
270 const c2R = r0 + 0.5 * len;
271 const c2x = cx0 + Math.cos(c2Ang) * c2R;
272 const c2y = cy0 + Math.sin(c2Ang) * c2R;
273
274 // Second cubic (mirror): tip down to end anchor.
275 const c3Ang = theta - halfAngle * (2 / 9);
276 const c3R = r0 + 0.5 * len;
277 const c3x = cx0 + Math.cos(c3Ang) * c3R;
278 const c3y = cy0 + Math.sin(c3Ang) * c3R;
279
280 const c4Ang = theta - halfAngle * (7 / 9);
281 const c4R = r0 + 0.7 * len;
282 const c4x = cx0 + Math.cos(c4Ang) * c4R;
283 const c4y = cy0 + Math.sin(c4Ang) * c4R;
284
285 ctx.beginPath();
286 ctx.moveTo(sx, sy);
287 ctx.bezierCurveTo(c1x, c1y, c2x, c2y, tx, ty);
288 ctx.bezierCurveTo(c3x, c3y, c4x, c4y, ex, ey);
289 // Close the base with an arc along the inner circle from the
290 // end anchor back to the start anchor (anticlockwise=true in
291 // canvas coords traces the short arc from endAng up to
292 // startAng).
293 ctx.arc(cx0, cy0, r0, endAng, startAng, true);
294 ctx.closePath();
295 ctx.stroke();
296 };
297
298 const drawV2 = (t: number) => {
299 const minR = Math.max(width, height) * 0.015;
300 const maxR = Math.max(width, height) * 1.0;
301 const logRange = Math.log(maxR / minR);
302 // Petal-band aspect (`r1/r0`) is decoupled from inter-layer
303 // spacing — the LaTeX reference uses 1.2 for nice wide petals.
304 const petalRatio = 1.2;
305 const clock = ((t * 0.001 * zoom) % 1 + 1) % 1;
306 const tSec = t * 0.001;
307
308 drawBindu();
309
310 const cap = 0.5 * intensity;
311
312 for (let i = 0; i < nLayers; i++) {
313 const phase = (i / nLayers + clock) % 1;
314 const r0 = minR * Math.exp(phase * logRange);
315 const r1 = r0 * petalRatio;
316 // Asymmetric envelope: quick fade-in near the bindu, then a
317 // steep (1−phase)^2 falloff so outer layers dissolve faster
318 // than they grow.
319 const fadeIn = Math.min(1, phase / 0.12);
320 const fadeOut = Math.pow(1 - phase, 1.8);
321 const fade = fadeIn * fadeOut;
322 const a = cap * fade;
323 if (a < 0.01) continue;
324
325 const spinScale = 0.06 / Math.max(0.06, phase + 0.05);
326 const spin = tSec * spinScale * (i % 2 === 0 ? 1 : -1);
327
328 // Bounding circles for this lotus rim — faint so the petal
329 // silhouettes dominate (matches the block-print reference).
330 ctx.lineWidth = 1;
331 ctx.strokeStyle = rgba(a * 0.4);
332 ctx.beginPath();
333 ctx.arc(cx0, cy0, r0, 0, Math.PI * 2);
334 ctx.stroke();
335 ctx.beginPath();
336 ctx.arc(cx0, cy0, r1, 0, Math.PI * 2);
337 ctx.stroke();
338
339 const k = 8;
340 const halfAngle = Math.PI / k;
341
342 ctx.lineWidth = 1.05;
343 ctx.strokeStyle = rgba(a * 0.9);
344 for (let p = 0; p < k; p++) {
345 const ang = spin + (p * 2 * Math.PI) / k;
346 strokeLotusPetal(ang, r0, r1, halfAngle);
347 }
348 }
349 };
350
351 // ============================================================
352 // Frame loop
353 // ============================================================
354
355 const draw = (t: number) => {
356 ctx.clearRect(0, 0, width, height);
357 // Variant 2 (`drawV2`) is intentionally unreachable from the
358 // public API — uncomment the case below to revisit it.
359 void drawV2;
360 switch (variant) {
361 case 1:
362 default:
363 drawV1(t);
364 break;
365 }
366 };
367
368 let raf = 0;
369 let visible = true;
370 // The page's "Pause motion" control (WCAG 2.2.2) sets this class.
371 const isPaused = () =>
372 document.documentElement.classList.contains("alc-motion-paused");
373 // Playback rate for every motion in the graph (zoom and ring spin).
374 const SPEED = 0.5;
375 // Own clock, advanced only while running, so pausing freezes the frame
376 // and resuming continues from it without a jump.
377 let clock = 0;
378 let last: number | null = null;
379 const loop = (t: number) => {
380 if (last !== null) clock += t - last;
381 last = t;
382 draw(clock * SPEED);
383 if (visible && !reduced && !isPaused()) {
384 raf = requestAnimationFrame(loop);
385 } else {
386 last = null;
387 }
388 };
389 window.addEventListener("alc-motion-change", () => {
390 cancelAnimationFrame(raf);
391 last = null;
392 if (!isPaused() && visible && !reduced) raf = requestAnimationFrame(loop);
393 });
394
395 resize();
396 if (reduced || isPaused()) {
397 draw(0);
398 } else {
399 raf = requestAnimationFrame(loop);
400 }
401
402 let resizeQueued = false;
403 const onResize = () => {
404 if (resizeQueued) return;
405 resizeQueued = true;
406 requestAnimationFrame(() => {
407 resizeQueued = false;
408 const changed = resize();
409 rgb = resolveRGB();
410 // In reduced-motion mode the RAF loop is stopped, so when the
411 // canvas was actually resized we need to explicitly redraw the
412 // static frame. If `resize()` no-op'd (mobile URL-bar wiggle
413 // with unchanged dimensions) we don't need to redraw at all.
414 if (reduced && changed) draw(0);
415 });
416 };
417 window.addEventListener("resize", onResize, { passive: true });
418
419 // The stroke color is derived from `getComputedStyle(canvas).color`,
420 // which resolves `var(--alc-fg-1)` against the current theme. When
421 // the user toggles light/dark mid-session the cached `rgb` would
422 // otherwise stay frozen on the previous theme until the next resize
423 // — observable as a flash of stale-color strokes after the theme
424 // switch. Re-resolve whenever `<html data-theme>` changes.
425 const themeObs = new MutationObserver(() => {
426 rgb = resolveRGB();
427 if (reduced) draw(0);
428 });
429 themeObs.observe(document.documentElement, {
430 attributes: true,
431 attributeFilter: ["data-theme"],
432 });
433
434 // Honor live changes to the user's `prefers-reduced-motion` setting.
435 // When the user enables reduced motion mid-session, stop the loop and
436 // hold a static frame; when they disable it, resume the animation.
437 const onMotionChange = (e: MediaQueryListEvent) => {
438 reduced = e.matches;
439 cancelAnimationFrame(raf);
440 if (reduced) {
441 draw(0);
442 } else if (visible && !isPaused()) {
443 raf = requestAnimationFrame(loop);
444 }
445 };
446 motionMq.addEventListener("change", onMotionChange);
447
448 if (centerEl && typeof ResizeObserver !== "undefined") {
449 const ro = new ResizeObserver(() => {
450 measureCenter();
451 if (reduced) draw(0);
452 });
453 ro.observe(centerEl);
454 const hero = canvas.parentElement;
455 if (hero) ro.observe(hero);
456 }
457
458 const io = new IntersectionObserver(
459 (entries) => {
460 const wasVisible = visible;
461 visible = entries[0]?.isIntersecting ?? true;
462 if (visible && !wasVisible) {
463 // Restart the loop when the canvas scrolls back into view, or
464 // — in reduced-motion mode — redraw the single static frame
465 // (it may have been cleared / sized while off-screen).
466 if (reduced) draw(0);
467 else if (!isPaused()) raf = requestAnimationFrame(loop);
468 } else if (!visible) {
469 cancelAnimationFrame(raf);
470 }
471 },
472 { threshold: 0 },
473 );
474 io.observe(canvas);
475 };
476
477 const start = () => {
478 document
479 .querySelectorAll<HTMLCanvasElement>("canvas.alc-hero__bg-graph")
480 .forEach(init);
481 };
482
483 if (document.readyState === "loading") {
484 document.addEventListener("DOMContentLoaded", start, { once: true });
485 } else {
486 start();
487 }
488</script>