const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const surf = (x, t, z) => {
const Q = x * x + A * t + z * z - 1;
return Q * Q * Q - z * z * z * (x * x + B * t);
const l = [-0.35, 0.5, 0.74];
const n = Math.hypot(l[0], l[1], l[2]);
return [l[0] / n, l[1] / n, l[2] / n];
const STYLE_ID = "exp-heart-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 CFG = { ...DEFAULTS };
const wrap = document.createElement("div");
wrap.className = "heart";
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 = `${m[0]},${m[1]},${m[2]}`;
const themeObs = new MutationObserver(resolveInk);
themeObs.observe(document.documentElement, {
attributeFilter: ["data-theme", "class"]
const mobile = Math.min(window.innerWidth, window.innerHeight) < 1024;
const NX = mobile ? 70 : 100;
const NZ = mobile ? 84 : 120;
for (let i = 0; i < NX; i++) {
const x = X0 + (X1 - X0) * i / (NX - 1);
for (let j = 0; j < NZ; j++) {
const z = Z0 + (Z1 - Z0) * j / (NZ - 1);
if (surf(x, 0, z) >= 0) continue;
for (let s = 1; s <= 150; s++) {
if (surf(x, t2, z) >= 0) {
for (let k = 0; k < 22; k++) {
if (surf(x, m, z) < 0) a = m;
const Q = x * x + A * t + z * z - 1;
const gx = 2 * x * (3 * Q2 - z * z * z);
const gz = 6 * z * Q2 - 3 * z * z * (x * x + B * t);
const gy = y * (13.5 * Q2 - 0.225 * z * z * z);
for (const sign of [1, -1]) {
for (let k = 0; k < COUNT; k++) {
for (let k = 0; k < COUNT; k++) {
ext = Math.max(ext, Math.abs(px[k]), Math.abs(py[k]), Math.abs(pz[k]));
maxR = Math.max(maxR, Math.hypot(px[k], pz[k]));
const P = new Float32Array(COUNT * 3);
const NRM = new Float32Array(COUNT * 3);
const band = new Uint8Array(COUNT);
for (let k = 0; k < COUNT; k++) {
P[k * 3 + 1] = py[k] / ext;
P[k * 3 + 2] = pz[k] / ext;
const nl = Math.hypot(nxA[k], nyA[k], nzA[k]) || 1;
NRM[k * 3] = nxA[k] / nl;
NRM[k * 3 + 1] = nyA[k] / nl;
NRM[k * 3 + 2] = nzA[k] / nl;
const rNorm = Math.hypot(px[k], pz[k]) / maxR;
band[k] = Math.min(BANDS - 1, rNorm * BANDS | 0);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
fit = Math.min(W * 0.4, H * 0.46);
let w = reduced ? 0 : 0.35;
const bandK = new Float64Array(BANDS);
const bandRate = new Float64Array(BANDS);
for (let bI = 0; bI < BANDS; bI++) bandRate[bI] = 1 / (0.72 - 0.38 * (bI / BANDS));
const cosK = new Float64Array(BANDS);
const sinK = new Float64Array(BANDS);
const ac = new AbortController();
wrap.classList.add("is-drag");
wrap.setPointerCapture?.(e.pointerId);
w = clamp(w + (e.clientX - lastX) * CFG.spin, -SPIN_CLAMP, SPIN_CLAMP);
wrap.classList.remove("is-drag");
wrap.addEventListener("pointerup", end, { signal });
wrap.addEventListener("pointercancel", end, { signal });
const [lx, ly, lz] = LIGHT;
const tick = (time, dtMs) => {
const dt = Math.min(dtMs, 40) / 1e3 || 0.016;
w *= Math.exp(-dt / CFG.coast);
pitch += (0.14 + 0.05 * Math.sin(time * 1e-4) - pitch) * Math.min(1, dt * 4);
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
for (let bI = 0; bI < BANDS; bI++) {
if (reduced) bandK[bI] = yaw;
else bandK[bI] += (yaw - bandK[bI]) * Math.min(1, dt * bandRate[bI]);
cosK[bI] = Math.cos(bandK[bI]);
sinK[bI] = Math.sin(bandK[bI]);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = `rgb(${ink})`;
for (let k = 0; k < COUNT; k++) {
const xr = X * ck + Z * sk;
const zr = -X * sk + Z * ck;
const yr2 = Y * cp - zr * sp;
const zr2 = Y * sp + zr * cp;
const nY = NRM[k * 3 + 1];
const nZ = NRM[k * 3 + 2];
const nxr = nX * ck + nZ * sk;
const nzr = -nX * sk + nZ * ck;
const nzr2 = nY * sp + nzr * cp;
if (nzr2 < 0.02) continue;
const nyr2 = nY * cp - nzr * sp;
const scale = Math.min(MAX_SCALE, CFG.focal / (CFG.focal - depth));
const sx = cx + xr * fit * scale;
const sy = cy - yr2 * fit * scale;
const zd = clamp(0.5 + zr2 * 0.5, 0, 1);
const rad = Math.min(1.6, scale) * (0.2 + zd);
const lam = 0.32 + 0.6 * Math.max(0, nxr * lx + nyr2 * ly + nzr2 * lz);
const tier = lam < 0.26 ? 0.42 : lam > 0.62 ? 1 : 0.68;
ctx.globalAlpha = Math.min(0.5, tier * (0.4 + 0.6 * zd));
ctx.arc(sx, sy, rad, 0, Math.PI * 2);
const ro = new ResizeObserver(measure);
const set = (key, value) => {
if (key in CFG) CFG[key] = Number(value);
gsap.ticker.remove(tick);
experiment.mount(document.querySelector(".stage"));