| 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 | |
| 25 | interface 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 | |
| 42 | const { |
| 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> |