const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const RIPPLE_SPEED = 620;
pull: { stiffness: 0.055, damping: 0.18, mass: 1 }
const STYLE_ID = "exp-polka-style";
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const wrap = document.createElement("div");
const canvas = document.createElement("canvas");
wrap.appendChild(canvas);
const ctx = canvas.getContext("2d");
const resolveInk = () => {
const probe = document.createElement("span");
probe.style.cssText = "color:var(--ink);position:absolute;visibility:hidden";
const m = getComputedStyle(probe).color.match(/\d+/g);
if (m) ink = [Number(m[0]), Number(m[1]), Number(m[2])];
const themeObs = new MutationObserver(resolveInk);
themeObs.observe(document.documentElement, {
attributeFilter: ["data-theme", "class"]
dpr = Math.min(window.devicePixelRatio || 1, 2);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pitch = clamp(Math.min(W, H) / 18, 26, 44);
const nextCols = Math.max(2, Math.round(W / pitch));
const nextRows = Math.max(2, Math.round(H / pitch));
if (nextCols === cols && nextRows === rows && dots.length) {
const hx = gapX * (i % cols + 0.5);
const hy = gapY * (Math.floor(i / cols) + 0.5);
const seed = (bloom) => {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const hx = gapX * (c + 0.5);
const hy = gapY * (r + 0.5);
appear: reduced || !bloom ? 1 : 0
if (bloom && !reduced) intro();
const build = (bloom = true) => {
if (measure()) seed(bloom);
dots.forEach((d) => d.appear = 0);
introTween = gsap.to(dots, {
ease: "elastic.out(1, 0.55)",
stagger: { each: 35e-4, grid: [rows, cols], from: "center" }
const mouse = { x: 0, y: 0, px: 0, py: 0, vx: 0, vy: 0, active: false };
const ac = new AbortController();
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
wrap.addEventListener("pointerleave", () => mouse.active = false, { signal });
ripples.push({ x: p.x, y: p.y, radius: 0, strength: RIPPLE_PUSH });
const tick = (time, dtMs) => {
const dt = Math.min(dtMs, 40) / 1e3 || 0.016;
const R2 = P.repelR * P.repelR;
mouse.vx = mouse.x - mouse.px;
mouse.vy = mouse.y - mouse.py;
for (let i = ripples.length - 1; i >= 0; i--) {
rp.radius += RIPPLE_SPEED * dt;
if (rp.strength < 0.05 || rp.radius > Math.hypot(W, H)) ripples.splice(i, 1);
ctx.clearRect(0, 0, W, H);
const [ir, ig, ib] = ink;
for (let k = 0; k < dots.length; k++) {
const hx = reduced ? d.hx : d.hx + Math.sin(d.hy * 0.02 + time * 12e-4) * WAVE;
const hy = reduced ? d.hy : d.hy + Math.cos(d.hx * 0.02 + time * 1e-3) * WAVE;
const springK = P.pull.stiffness / P.pull.mass;
let ax = (hx - d.x) * springK;
let ay = (hy - d.y) * springK;
const dx = d.x - mouse.x;
const dy = d.y - mouse.y;
const dd = dx * dx + dy * dy;
const dist = Math.sqrt(dd) || 1;
const f = 1 - dist / P.repelR;
ax += nx * f * P.push + mouse.vx * f * VEL_PUSH;
ay += ny * f * P.push + mouse.vy * f * VEL_PUSH;
for (let ri = 0; ri < ripples.length; ri++) {
const dist = Math.hypot(dx, dy) || 1;
const edge = Math.abs(dist - rp.radius);
if (edge < RIPPLE_WIDTH) {
const f = (1 - edge / RIPPLE_WIDTH) * rp.strength;
if (f * 0.4 > bloom) bloom = Math.min(1, f * 0.4);
const keep = 1 - P.pull.damping;
d.vx = (d.vx + ax) * keep;
d.vy = (d.vy + ay) * keep;
const rad = DOT_R * (0.4 + 0.6 * d.appear) * (1 + bloom * P.bloom);
const alpha = d.appear * (REST_ALPHA + bloom * (1 - REST_ALPHA));
if (alpha < 4e-3 || rad < 0.1) continue;
ctx.arc(d.x, d.y, rad, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${ir},${ig},${ib},${alpha})`;
const ro = new ResizeObserver(() => build(false));
const set = (key, value) => {
if (value && typeof value === "object") P.pull = value;
gsap.ticker.remove(tick);
experiment.mount(document.querySelector(".stage"));