{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "truchet-flip",
  "title": "Truchet Flip",
  "description": "Quarter-circle Truchet tiles snap a quarter turn as a wave crosses the grid, rerouting continuous lines until they settle into concentric loops, a calm field, or a circle of small rings.",
  "dependencies": ["remotion"],
  "files": [
    {
      "path": "registry/remocn/truchet-flip/index.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { random, useCurrentFrame, useVideoConfig } from \"remotion\";\n\nexport type TruchetFlipWaveMode = \"radial\" | \"diagonal\" | \"random\";\nexport type TruchetFlipResolve = \"rings\" | \"field\" | \"silhouette\";\nexport type TruchetFlipVariant = \"stroke\" | \"filled\";\nexport type TruchetFlipOrientation = 0 | 1;\n\nexport interface TruchetFlipProps {\n  tiles?: number;\n  weight?: number;\n  passes?: number;\n  waveMode?: TruchetFlipWaveMode;\n  resolve?: TruchetFlipResolve;\n  variant?: TruchetFlipVariant;\n  color?: string;\n  secondaryColor?: string;\n  seed?: number;\n  speed?: number;\n  loop?: boolean;\n  className?: string;\n}\n\ntype Paint = \"color\" | \"secondaryColor\" | \"className\";\ntype Unset = \"secondaryColor\" | \"className\";\n\nexport interface TruchetFlipOptions extends Omit<TruchetFlipProps, Paint> {\n  width?: number;\n  height?: number;\n  fps?: number;\n}\n\nexport interface TruchetFlipTile {\n  col: number;\n  row: number;\n  x: number;\n  y: number;\n  alternate: boolean;\n  initial: TruchetFlipOrientation;\n  target: TruchetFlipOrientation;\n  delay: number;\n}\n\nexport const truchetFlipDefaults: Required<Omit<TruchetFlipProps, Unset>> = {\n  tiles: 10,\n  weight: 0.2,\n  passes: 2,\n  waveMode: \"radial\",\n  resolve: \"rings\",\n  variant: \"stroke\",\n  color: \"#ffffff\",\n  seed: 1,\n  speed: 1,\n  loop: false,\n};\n\nconst LEAD = 6;\nconst SPREAD = 30;\nconst SNAP = 12;\nconst SNAP_SETTLE = 8;\nconst SNAP_RISE = 4;\nconst SNAP_DAMPING = 0.65;\nconst GAP = 24;\nconst RESOLVE_GAP = 30;\nconst HOLD = 18;\nconst LOOP_HOLD = 24;\nconst MAX_PASSES = 4;\nconst MIN_TILES = 4;\nconst MAX_TILES = 24;\nconst MIN_WEIGHT = 0.04;\nconst MAX_WEIGHT = 0.34;\nconst SILHOUETTE = 0.38;\nconst OVERLAP = 0.75;\nconst SNAP_FREQUENCY = (Math.PI - Math.acos(SNAP_DAMPING)) / SNAP_RISE;\nconst SNAP_RATIO = SNAP_DAMPING / Math.sqrt(1 - SNAP_DAMPING ** 2);\n\nconst finite = (value: number | undefined, fallback: number) =>\n  typeof value === \"number\" && Number.isFinite(value) ? value : fallback;\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\nconst round = (value: number) => Math.round(value * 1000) / 1000;\nconst isOdd = (value: number) => Math.abs(value % 2) === 1;\nconst turn = (wave: number) => (wave % 2 === 0 ? 1 : -1);\nconst rateOf = (speed: number | undefined) =>\n  Math.max(0, finite(speed, truchetFlipDefaults.speed));\nconst waveOf = (value: string | undefined): TruchetFlipWaveMode =>\n  value === \"diagonal\" || value === \"random\" ? value : \"radial\";\nconst resolveOf = (value: string | undefined): TruchetFlipResolve =>\n  value === \"field\" || value === \"silhouette\" ? value : \"rings\";\nconst variantOf = (value: string | undefined): TruchetFlipVariant =>\n  value === \"filled\" ? value : \"stroke\";\n\nfunction smoothstep(value: number) {\n  const t = clamp(value, 0, 1);\n  return t * t * (3 - 2 * t);\n}\n\nfunction mazeKey(seed: number, col: number, row: number) {\n  return random(`truchet-flip-${seed}-${col}-${row}`);\n}\n\nfunction waveKey(seed: number, col: number, row: number) {\n  return random(`truchet-flip-${seed}-wave-${col}-${row}`);\n}\n\nexport function getTruchetFlipSnap(frames: number) {\n  if (Number.isNaN(frames) || frames <= 0) return 0;\n  if (frames >= SNAP) return 1;\n  const phase = SNAP_FREQUENCY * frames;\n  const decay = Math.exp(-SNAP_RATIO * phase);\n  const ring = decay * (Math.cos(phase) + SNAP_RATIO * Math.sin(phase));\n  const settle = smoothstep((frames - SNAP_SETTLE) / (SNAP - SNAP_SETTLE));\n  return 1 - ring * (1 - settle);\n}\n\nexport function getTruchetFlipTimeline(options: TruchetFlipOptions = {}) {\n  const requested = finite(options.passes, truchetFlipDefaults.passes);\n  const passes = Math.round(clamp(requested, 0, MAX_PASSES));\n  const starts = Array.from({ length: passes }, (_, i) => LEAD + i * GAP);\n  const lastPass = LEAD + Math.max(0, passes - 1) * GAP;\n  const resolveStart = passes === 0 ? LEAD : lastPass + RESOLVE_GAP;\n  const settled = resolveStart + SPREAD + SNAP;\n  const returnStart = settled + LOOP_HOLD;\n  const loop = options.loop === true;\n  const duration = loop ? returnStart + SPREAD + SNAP : settled + HOLD;\n  return {\n    passes,\n    starts,\n    resolveStart,\n    settled,\n    returnStart,\n    loop,\n    duration,\n  };\n}\n\nexport type TruchetFlipTimeline = ReturnType<typeof getTruchetFlipTimeline>;\n\nexport const truchetFlipLength = getTruchetFlipTimeline().settled;\n\nexport function getTruchetFlipDuration(options: TruchetFlipOptions = {}) {\n  const rate = rateOf(options.speed);\n  const { duration } = getTruchetFlipTimeline(options);\n  return rate === 0 ? 1 : Math.max(1, Math.ceil(duration / rate));\n}\n\nexport function getTruchetFlipTime(\n  frame: number,\n  options: TruchetFlipOptions = {},\n) {\n  const scale = 30 / Math.max(1, finite(options.fps, 30));\n  const time = Math.max(0, finite(frame, 0)) * scale * rateOf(options.speed);\n  const { duration, loop } = getTruchetFlipTimeline(options);\n  return loop ? time % duration : time;\n}\n\nexport function getTruchetFlipTarget(\n  resolve: TruchetFlipResolve,\n  col: number,\n  row: number,\n  tiles = truchetFlipDefaults.tiles,\n): TruchetFlipOrientation {\n  if (resolve === \"field\") return 0;\n  if (resolve === \"silhouette\") {\n    const inside = Math.hypot(col + 0.5, row + 0.5) < tiles * SILHOUETTE;\n    return inside && isOdd(col + row) ? 1 : 0;\n  }\n  const left = col < 0;\n  const above = row < 0;\n  return left === above ? 0 : 1;\n}\n\nexport function getTruchetFlipGrid(options: TruchetFlipOptions = {}) {\n  const width = Math.max(1, finite(options.width, 1280));\n  const height = Math.max(1, finite(options.height, 720));\n  const requested = finite(options.tiles, truchetFlipDefaults.tiles);\n  const count = Math.round(clamp(requested, MIN_TILES, MAX_TILES));\n  const size = Math.min(width, height) / count;\n  const halfCols = Math.ceil(width / 2 / size - 1e-6);\n  const halfRows = Math.ceil(height / 2 / size - 1e-6);\n  const seed = finite(options.seed, truchetFlipDefaults.seed);\n  const mode = waveOf(options.waveMode);\n  const resolve = resolveOf(options.resolve);\n  const cells: { col: number; row: number }[] = [];\n  for (let row = -halfRows; row < halfRows; row++) {\n    for (let col = -halfCols; col < halfCols; col++) cells.push({ col, row });\n  }\n  const ranks: number[] = [];\n  if (mode === \"random\") {\n    const keys = cells.map(({ col, row }) => waveKey(seed, col, row));\n    const order = keys.map((_, index) => index);\n    order.sort((a, b) => keys[a] - keys[b]);\n    for (const [position, index] of order.entries()) ranks[index] = position;\n  }\n  const shift = halfCols + halfRows;\n  const diagonals = 2 * (shift - 1);\n  const nearest = Math.hypot(0.5, 0.5);\n  const farthest = Math.hypot(halfCols - 0.5, halfRows - 0.5);\n  const delayOf = (col: number, row: number, index: number) => {\n    if (mode === \"random\") {\n      return (SPREAD * ranks[index]) / (cells.length - 1);\n    }\n    if (mode === \"diagonal\") {\n      return (SPREAD * (col + row + shift)) / diagonals;\n    }\n    const distance = Math.hypot(col + 0.5, row + 0.5) - nearest;\n    return (SPREAD * distance) / Math.max(1e-6, farthest - nearest);\n  };\n  const tiles: TruchetFlipTile[] = cells.map(({ col, row }, index) => ({\n    col,\n    row,\n    x: round(width / 2 + (col + 0.5) * size),\n    y: round(height / 2 + (row + 0.5) * size),\n    alternate: isOdd(col + row),\n    initial: mazeKey(seed, col, row) < 0.5 ? 0 : 1,\n    target: getTruchetFlipTarget(resolve, col, row, count),\n    delay: round(clamp(delayOf(col, row, index), 0, SPREAD)),\n  }));\n  return {\n    width,\n    height,\n    count,\n    size,\n    unit: Math.min(width, height) / 720,\n    tiles,\n  };\n}\n\nexport type TruchetFlipGrid = ReturnType<typeof getTruchetFlipGrid>;\n\nexport function getTruchetFlipAngle(\n  tile: TruchetFlipTile,\n  time: number,\n  timeline: TruchetFlipTimeline,\n) {\n  let turns = 0;\n  for (const [wave, start] of timeline.starts.entries()) {\n    turns += turn(wave) * getTruchetFlipSnap(time - start - tile.delay);\n  }\n  const reached = (tile.initial + timeline.passes) % 2;\n  if (reached !== tile.target) {\n    const since = time - timeline.resolveStart - tile.delay;\n    turns += turn(timeline.passes) * getTruchetFlipSnap(since);\n  }\n  if (timeline.loop && tile.target !== tile.initial) {\n    const since = time - timeline.returnStart - tile.delay;\n    turns += turn(timeline.passes + 1) * getTruchetFlipSnap(since);\n  }\n  return (tile.initial + turns) * 90;\n}\n\nexport function getTruchetFlipTilePath(\n  size: number,\n  variant: TruchetFlipVariant = \"stroke\",\n  overlap = OVERLAP,\n) {\n  const half = Math.max(0, finite(size, 0)) / 2;\n  const pad = clamp(finite(overlap, OVERLAP), 0, half / 4);\n  const radius = `${round(half)} ${round(half)}`;\n  const point = (x: number, y: number) => `${round(x)} ${round(y)}`;\n  if (variant === \"filled\") {\n    const reach = Math.sqrt(half * half - pad * pad);\n    const sector = (cx: number, cy: number, side: number) => {\n      const corner = point(cx - side * pad, cy - side * pad);\n      const start = point(cx + side * reach, cy - side * pad);\n      const end = point(cx - side * pad, cy + side * reach);\n      return `M ${corner} L ${start} A ${radius} 0 0 1 ${end} Z`;\n    };\n    return `${sector(-half, -half, 1)} ${sector(half, half, -1)}`;\n  }\n  const bend = half > 0 ? pad / half : 0;\n  const cos = half * Math.cos(bend);\n  const sin = half * Math.sin(bend);\n  const arc = (cx: number, cy: number, side: number) => {\n    const start = point(cx + side * cos, cy - side * sin);\n    const end = point(cx - side * sin, cy + side * cos);\n    return `M ${start} A ${radius} 0 0 1 ${end}`;\n  };\n  return `${arc(-half, -half, 1)} ${arc(half, half, -1)}`;\n}\n\nexport function getTruchetFlipState(\n  frame: number,\n  options: TruchetFlipOptions = {},\n  grid: TruchetFlipGrid = getTruchetFlipGrid(options),\n) {\n  const timeline = getTruchetFlipTimeline(options);\n  const time = getTruchetFlipTime(frame, options);\n  const variant = variantOf(options.variant);\n  const requested = finite(options.weight, truchetFlipDefaults.weight);\n  const weight = clamp(requested, MIN_WEIGHT, MAX_WEIGHT);\n  return {\n    ...grid,\n    time,\n    timeline,\n    variant,\n    strokeWidth: round(grid.size * weight),\n    path: getTruchetFlipTilePath(grid.size, variant, OVERLAP * grid.unit),\n    tiles: grid.tiles.map((tile) => {\n      const angle = getTruchetFlipAngle(tile, time, timeline);\n      const rotation = round(angle);\n      const transform = `translate(${tile.x} ${tile.y}) rotate(${rotation})`;\n      return { ...tile, angle, transform };\n    }),\n  };\n}\n\nexport function TruchetFlip({\n  tiles = truchetFlipDefaults.tiles,\n  weight = truchetFlipDefaults.weight,\n  passes = truchetFlipDefaults.passes,\n  waveMode = truchetFlipDefaults.waveMode,\n  resolve = truchetFlipDefaults.resolve,\n  variant = truchetFlipDefaults.variant,\n  color = truchetFlipDefaults.color,\n  secondaryColor,\n  seed = truchetFlipDefaults.seed,\n  speed = truchetFlipDefaults.speed,\n  loop = truchetFlipDefaults.loop,\n  className,\n}: TruchetFlipProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const grid = useMemo(() => {\n    const layout = { width, height, tiles, seed, waveMode, resolve };\n    return getTruchetFlipGrid(layout);\n  }, [width, height, tiles, seed, waveMode, resolve]);\n  const motion = { fps, weight, passes, variant, speed, loop };\n  const state = getTruchetFlipState(frame, motion, grid);\n  const filled = state.variant === \"filled\";\n  const primary = state.tiles.filter((tile) => !tile.alternate);\n  const alternate = state.tiles.filter((tile) => tile.alternate);\n  const layers = [\n    { key: \"primary\", paint: color, tiles: primary },\n    { key: \"alternate\", paint: secondaryColor ?? color, tiles: alternate },\n  ];\n\n  return (\n    <div\n      className={className}\n      style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}\n    >\n      <svg\n        width=\"100%\"\n        height=\"100%\"\n        viewBox={`0 0 ${width} ${height}`}\n        role=\"img\"\n        aria-label=\"Truchet tile pattern\"\n        style={{ display: \"block\" }}\n      >\n        {layers.map((layer) => (\n          <g\n            key={layer.key}\n            fill={filled ? layer.paint : \"none\"}\n            stroke={filled ? \"none\" : layer.paint}\n            strokeWidth={filled ? undefined : state.strokeWidth}\n          >\n            {layer.tiles.map((tile) => (\n              <path\n                key={`${tile.col}:${tile.row}`}\n                d={state.path}\n                transform={tile.transform}\n              />\n            ))}\n          </g>\n        ))}\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/truchet-flip.tsx"
    }
  ],
  "type": "registry:component"
}
