{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "math-curves",
  "title": "Math Curves",
  "description": "Framework-agnostic math engine for parametric curve animations (rose, lissajous, butterfly, hypotrochoid, cardioid, lemniscate, fourier, spiral, heart)",
  "dependencies": [],
  "registryDependencies": [
    "@boldkit/utils"
  ],
  "files": [
    {
      "path": "registry/default/lib/math-curves.ts",
      "content": "/**\n * math-curves.ts\n * Framework-agnostic math engine for parametric curve animations.\n * All coordinates are in [0, 100] SVG space, center at (50, 50).\n * progress: 0–1 (fraction of full curve loop)\n * detailScale: 0.52–1.0 (breathing oscillator, see getDetailScale)\n */\n\nexport type LoaderCurveKey =\n  | 'rose' | 'lissajous' | 'butterfly' | 'hypotrochoid'\n  | 'cardioid' | 'lemniscate' | 'fourier' | 'rose3'\n  | 'astroid' | 'deltoid' | 'nephroid' | 'epicycloid'\n  | 'superellipse' | 'triskelion' | 'involute'\n  | 'spiral' | 'heart'\n\nexport type ProgressCurveKey =\n  | 'spiral' | 'heart' | 'lissajous' | 'cardioid' | 'rose'\n  | 'astroid' | 'superellipse' | 'deltoid' | 'nephroid'\n\nexport type BackgroundCurveKey =\n  | 'rose' | 'lissajous' | 'fourier' | 'spiral'\n  | 'triskelion' | 'involute' | 'epicycloid'\n\nexport type CurveKey = LoaderCurveKey | ProgressCurveKey | BackgroundCurveKey\n\ntype Point = { x: number; y: number }\n\ninterface CurveDefinition {\n  /** t parameter range — progress 0→1 maps to tMin→tMax */\n  tMin: number\n  tMax: number\n  /** Default breathing cycle duration in ms */\n  pulseDurationMs: number\n  /** Default segment count for buildPath */\n  defaultSegments: number\n  /** Core parametric function — returns {x, y} in [0,100] space */\n  compute: (t: number, detailScale: number) => Point\n  /**\n   * Progress fractions (0–1 exclusive) where the curve is discontinuous.\n   * buildPath will insert M (moveto) instead of L at these points.\n   */\n  discontinuities?: number[]\n}\n\nconst CURVE_DEFS: Record<string, CurveDefinition> = {\n  // 5-petal rose: r = a·cos(5θ), odd k → full flower in [0, π]\n  rose: {\n    tMin: 0,\n    tMax: Math.PI,\n    pulseDurationMs: 5000,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const a = 30 + ds * 8\n      const r = a * Math.cos(5 * t)\n      return { x: 50 + r * Math.cos(t), y: 50 + r * Math.sin(t) }\n    },\n  },\n\n  // 3-petal rose: r = a·cos(3θ), odd k → full flower in [0, π]\n  rose3: {\n    tMin: 0,\n    tMax: Math.PI,\n    pulseDurationMs: 5500,\n    defaultSegments: 240,\n    compute: (t, ds) => {\n      const a = 34 + ds * 6\n      const r = a * Math.cos(3 * t)\n      return { x: 50 + r * Math.cos(t), y: 50 + r * Math.sin(t) }\n    },\n  },\n\n  // Lissajous figure (3:4 ratio): x = A·sin(3t + π/2), y = B·sin(4t)\n  lissajous: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 4800,\n    defaultSegments: 240,\n    compute: (t, ds) => {\n      const A = 34 + ds * 6\n      const B = A * 0.86\n      return { x: 50 + A * Math.sin(3 * t + Math.PI / 2), y: 50 + B * Math.sin(4 * t) }\n    },\n  },\n\n  // Butterfly curve: r = e^cos(t) - 2·cos(4t) - sin^5(t/12)\n  // Full closure at t = 12π (6 full rotations)\n  butterfly: {\n    tMin: 0,\n    tMax: 12 * Math.PI,\n    pulseDurationMs: 6000,\n    defaultSegments: 480,\n    compute: (t, ds) => {\n      const r =\n        Math.exp(Math.cos(t)) -\n        2 * Math.cos(4 * t) -\n        Math.pow(Math.sin(t / 12), 5)\n      const scale = 7 + ds * 2.5\n      return { x: 50 + scale * r * Math.cos(t), y: 50 + scale * r * Math.sin(t) }\n    },\n  },\n\n  // Hypotrochoid (spirograph): R=5, r=3, d varies with detailScale\n  // (R-r)/r = 2/3 → closes at t = 6π (3 outer rotations)\n  hypotrochoid: {\n    tMin: 0,\n    tMax: 6 * Math.PI,\n    pulseDurationMs: 5500,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const R = 5,\n        r = 3\n      const d = 3.5 + ds * 1.5 // 3.5–5.0\n      const scale = 5\n      const x = scale * ((R - r) * Math.cos(t) + d * Math.cos(((R - r) / r) * t))\n      const y = scale * ((R - r) * Math.sin(t) - d * Math.sin(((R - r) / r) * t))\n      return { x: 50 + x, y: 50 + y }\n    },\n  },\n\n  // Cardioid: r = a(1 - cos t), centered in the 100×100 viewBox\n  // Span on x: [50 - a, 50 + a], span on y: [50 - a, 50 + a] → fits with a ≤ 22\n  cardioid: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 5000,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const a = 18 + ds * 4\n      const r = a * (1 - Math.cos(t))\n      // Offset by +a so cusp is at (50 + a, 50) and loop extends left to (50 - a, 50)\n      return { x: 50 + a + r * Math.cos(t), y: 50 + r * Math.sin(t) }\n    },\n  },\n\n  // Bernoulli lemniscate: x = a·cos(t)/(1+sin²t), y = a·sin(t)·cos(t)/(1+sin²t)\n  lemniscate: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 4500,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const a = 36 + ds * 6\n      const sinT = Math.sin(t)\n      const denom = 1 + sinT * sinT\n      return {\n        x: 50 + (a * Math.cos(t)) / denom,\n        y: 50 + (a * Math.sin(t) * Math.cos(t)) / denom,\n      }\n    },\n  },\n\n  // Fourier sum: multiple interfering harmonics\n  fourier: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 6500,\n    defaultSegments: 480,\n    compute: (t, ds) => {\n      const a1 = 20\n      const a2 = 10 + ds * 6\n      const a3 = 7\n      const a4 = 4 + ds * 3\n      const x =\n        a1 * Math.cos(t) +\n        a2 * Math.cos(2 * t) +\n        a3 * Math.cos(3 * t) +\n        a4 * Math.cos(5 * t)\n      const y =\n        a1 * Math.sin(t) +\n        a2 * Math.sin(2 * t) -\n        a3 * Math.sin(3 * t) +\n        a4 * Math.sin(5 * t)\n      return { x: 50 + x, y: 50 + y }\n    },\n  },\n\n  // Archimedean spiral: r = b·t, progress 0→1 maps to 0 full revolutions outward\n  spiral: {\n    tMin: 0,\n    tMax: 4 * Math.PI,\n    pulseDurationMs: 5000,\n    defaultSegments: 200,\n    compute: (t, ds) => {\n      const b = 2.5 + ds * 0.5\n      const r = b * t\n      return { x: 50 + r * Math.cos(t), y: 50 + r * Math.sin(t) }\n    },\n  },\n\n  // Parametric heart: x = 16sin³t, y = -(13cost - 5cos2t - 2cos3t - cos4t)\n  heart: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 5200,\n    defaultSegments: 200,\n    compute: (t, ds) => {\n      const scale = 1.8 + ds * 0.4\n      const x = scale * 16 * Math.pow(Math.sin(t), 3)\n      const y =\n        -scale *\n        (13 * Math.cos(t) -\n          5 * Math.cos(2 * t) -\n          2 * Math.cos(3 * t) -\n          Math.cos(4 * t))\n      return { x: 50 + x, y: 50 + y }\n    },\n  },\n\n  // Astroid (4-cusped hypocycloid): x = a·cos³t, y = a·sin³t\n  astroid: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 4000,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const a = 38 + ds * 4\n      return {\n        x: 50 + a * Math.pow(Math.cos(t), 3),\n        y: 50 + a * Math.pow(Math.sin(t), 3),\n      }\n    },\n  },\n\n  // Deltoid (3-cusped hypocycloid, R=3 r=1): x=(2cos+cos2t), y=(2sin-sin2t)\n  deltoid: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 4800,\n    defaultSegments: 240,\n    compute: (t, ds) => {\n      const scale = 11 + ds * 2\n      return {\n        x: 50 + scale * (2 * Math.cos(t) + Math.cos(2 * t)),\n        y: 50 + scale * (2 * Math.sin(t) - Math.sin(2 * t)),\n      }\n    },\n  },\n\n  // Nephroid (2-cusped epicycloid, R=2 r=1): x=3cos(t)-cos(3t), y=3sin(t)-sin(3t)\n  nephroid: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 5200,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const scale = 9 + ds * 2\n      return {\n        x: 50 + scale * (3 * Math.cos(t) - Math.cos(3 * t)),\n        y: 50 + scale * (3 * Math.sin(t) - Math.sin(3 * t)),\n      }\n    },\n  },\n\n  // Epicycloid (5-cusped, R=5 r=1): x=6cos-cos6t, y=6sin-sin6t\n  epicycloid: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 5000,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const scale = 5 + ds * 0.5\n      return {\n        x: 50 + scale * (6 * Math.cos(t) - Math.cos(6 * t)),\n        y: 50 + scale * (6 * Math.sin(t) - Math.sin(6 * t)),\n      }\n    },\n  },\n\n  // Superellipse (Lamé curve): |x/a|^n + |y/a|^n = 1, n oscillates 2→4\n  superellipse: {\n    tMin: 0,\n    tMax: 2 * Math.PI,\n    pulseDurationMs: 6000,\n    defaultSegments: 360,\n    compute: (t, ds) => {\n      const a = 36\n      const n = 2 + ds * 2\n      const exp = 2 / n\n      const cosT = Math.cos(t)\n      const sinT = Math.sin(t)\n      return {\n        x: 50 + a * Math.sign(cosT) * Math.pow(Math.abs(cosT), exp),\n        y: 50 + a * Math.sign(sinT) * Math.pow(Math.abs(sinT), exp),\n      }\n    },\n  },\n\n  // Triskelion: 3 Archimedean spiral arms, 120° apart\n  triskelion: {\n    tMin: 0,\n    tMax: 6 * Math.PI,\n    pulseDurationMs: 5500,\n    defaultSegments: 360,\n    // Arms are discontinuous: each arm starts at center and ends at outer tip.\n    // Insert M moves at 1/3 and 2/3 so arms aren't connected by straight lines.\n    discontinuities: [1 / 3, 2 / 3],\n    compute: (t, ds) => {\n      const b = 32 + ds * 8\n      const arm = Math.floor(t / (2 * Math.PI))\n      const theta = t - arm * 2 * Math.PI\n      const offset = (arm * 2 * Math.PI) / 3\n      const r = b * (theta / (2 * Math.PI))\n      return {\n        x: 50 + r * Math.cos(theta + offset),\n        y: 50 + r * Math.sin(theta + offset),\n      }\n    },\n  },\n\n  // Involute of circle: x = a(cos t + t·sin t), y = a(sin t - t·cos t)\n  involute: {\n    tMin: 0,\n    tMax: 4 * Math.PI,\n    pulseDurationMs: 5800,\n    defaultSegments: 300,\n    compute: (t, ds) => {\n      const a = 2.5 + ds * 0.5\n      return {\n        x: 50 + a * (Math.cos(t) + t * Math.sin(t)),\n        y: 50 + a * (Math.sin(t) - t * Math.cos(t)),\n      }\n    },\n  },\n}\n\n/**\n * Returns {x, y} in [0, 100] coordinate space for a given curve at a progress position.\n * @param curve  - curve key (e.g. 'rose', 'lissajous')\n * @param progress - 0–1 position along the full curve loop\n * @param detailScale - breathing oscillator value 0.52–1.0, defaults to 1.0\n */\nexport function getPoint(curve: string, progress: number, detailScale = 1.0): Point {\n  const def = CURVE_DEFS[curve]\n  if (!def) return { x: 50, y: 50 }\n  const t = def.tMin + progress * (def.tMax - def.tMin)\n  return def.compute(t, detailScale)\n}\n\n/**\n * Returns the curve tangent angle in degrees at the given progress position.\n * Computed via finite difference: angle between (progress - ε) and (progress + ε).\n * Used to rotate the head <rect> to follow the curve direction.\n * @param curve - curve key\n * @param progress - 0–1\n * @param detailScale - defaults to 1.0\n */\nexport function getAngle(curve: string, progress: number, detailScale = 1.0): number {\n  const EPS = 0.001\n  const p1 = getPoint(curve, Math.max(0, progress - EPS), detailScale)\n  const p2 = getPoint(curve, Math.min(1, progress + EPS), detailScale)\n  const dx = p2.x - p1.x\n  const dy = p2.y - p1.y\n  // Fallback to larger epsilon when sample points are too close (e.g. spiral at progress=0)\n  if (dx * dx + dy * dy < 1e-6) {\n    const BIG_EPS = 0.01\n    const q1 = getPoint(curve, Math.max(0, progress - BIG_EPS), detailScale)\n    const q2 = getPoint(curve, Math.min(1, progress + BIG_EPS), detailScale)\n    return Math.atan2(q2.y - q1.y, q2.x - q1.x) * (180 / Math.PI)\n  }\n  return Math.atan2(dy, dx) * (180 / Math.PI)\n}\n\n/**\n * Returns a full SVG path string (M + L commands) for the guide track.\n * Default segment counts per curve type:\n *   rose/cardioid/lemniscate/hypotrochoid = 300\n *   lissajous/butterfly/rose3 = 240 (butterfly: 480)\n *   fourier = 480\n *   spiral/heart = 200\n * @param curve - curve key\n * @param detailScale - defaults to 1.0\n * @param segments - optional override\n */\nexport function buildPath(curve: string, detailScale = 1.0, segments?: number): string {\n  const def = CURVE_DEFS[curve]\n  if (!def) return ''\n  const n = segments ?? def.defaultSegments\n  const discontinuities = def.discontinuities ?? []\n  let d = ''\n  for (let i = 0; i <= n; i++) {\n    const progress = i / n\n    const { x, y } = getPoint(curve, progress, detailScale)\n    // Use M (moveto) at the start or at declared discontinuity points\n    const isDiscontinuous = discontinuities.some(\n      (dp) => Math.abs(progress - dp) < 0.5 / n\n    )\n    if (i === 0 || isDiscontinuous) {\n      d += ` M ${x.toFixed(2)} ${y.toFixed(2)}`\n    } else {\n      d += ` L ${x.toFixed(2)} ${y.toFixed(2)}`\n    }\n  }\n  return d.trim()\n}\n\n/**\n * Returns a sine-based breathing oscillator value in [0.52, 1.0].\n * Formula: 0.52 + ((sin(2π × (elapsed % pulseDurationMs) / pulseDurationMs) + 1) / 2) × 0.48\n * @param elapsed - current time in ms (e.g. performance.now())\n * @param pulseDurationMs - full breathing cycle duration\n */\nexport function getDetailScale(elapsed: number, pulseDurationMs: number): number {\n  const phase = (elapsed % pulseDurationMs) / pulseDurationMs\n  return 0.52 + ((Math.sin(2 * Math.PI * phase) + 1) / 2) * 0.48\n}\n\n/**\n * Returns the default pulse duration for a given curve key.\n * Useful for initializing the breathing animation.\n */\nexport function getCurvePulseDuration(curve: string): number {\n  return CURVE_DEFS[curve]?.pulseDurationMs ?? 5000\n}\n",
      "type": "registry:lib",
      "target": "lib/math-curves.ts"
    }
  ],
  "type": "registry:lib"
}