{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transit-lines",
  "title": "Transit Lines",
  "description": "Metro-map lines converge on a hub station that resolves into the logo as the next stop.",
  "dependencies": ["remotion", "@remotion/google-fonts", "@remotion/paths"],
  "files": [
    {
      "path": "registry/remocn/transit-lines/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/InstrumentSans\";\nimport { loadFont as loadManrope } from \"@remotion/google-fonts/Manrope\";\nimport { evolvePath } from \"@remotion/paths\";\nimport type React from \"react\";\nimport { useId } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst { fontFamily: sans } = loadFont(\"normal\", {\n  weights: [\"400\", \"500\"],\n  subsets: [\"latin\"],\n});\nconst manrope = loadManrope(\"normal\", {\n  weights: [\"500\", \"600\"],\n  subsets: [\"latin\"],\n});\n\nconst REMOCN_R_PATH =\n  \"M 0.01 0.81 C 0.01 1.73, 0.36 2.79, 1.09 4.13 C 4.91 11.04, 13.45 17.16, 21.7 18.9 C 22.94 19.16, 23.18 19.16, 51.39 19.27 C 76.07 19.36, 80.02 19.4, 81.32 19.57 C 89.89 20.69, 96.2 24.68, 99.38 31.01 C 103.19 38.56, 102.53 50.1, 97.91 57.07 C 94.66 61.96, 89.68 64.99, 83.26 66 C 82.81 66.07, 70.18 66.83, 55.2 67.69 C 24.82 69.43, 27.03 69.23, 24.18 70.4 C 19.9 72.15, 14.84 75.7, 10.65 79.89 C 4.86 85.68, 1.3 91.91, 0.25 98.13 C 0.12 98.85, 0.08 103.13, 0.04 116.66 L 0 134.26 9.5 134.26 L 19 134.26 19.05 119.41 C 19.1 103.27, 19.08 103.82, 19.82 101.04 C 21.79 93.65, 27.86 88.75, 36.45 87.63 C 37.23 87.53, 39.41 87.5, 43.57 87.53 C 50.12 87.59, 50.75 87.65, 53.22 88.45 C 56.61 89.56, 59.67 91.86, 62.02 95.07 C 62.52 95.76, 69.35 103.85, 77.2 113.07 C 85.04 122.28, 91.63 130.04, 91.85 130.32 C 92.07 130.59, 92.5 131.34, 92.82 131.97 C 93.52 133.35, 94.11 133.93, 95.06 134.13 C 95.5 134.23, 98.97 134.26, 106.36 134.23 L 117.01 134.19 100.82 113.07 C 91.91 101.45, 84.52 91.78, 84.39 91.57 C 83.36 89.89, 83.66 87.53, 85.09 86 C 85.79 85.25, 86.36 84.94, 88.07 84.38 C 96.18 81.72, 104.15 76.62, 109.97 70.36 C 120.59 58.93, 124.06 44.32, 119.44 30.43 C 114.59 15.81, 101.93 4.02, 87.64 0.83 C 83.93 0.01, 88.09 0.08, 41.85 0.04 L 0.01 0 0.01 0.81 Z\";\n\n/** Full logo as on remocn.dev: R mark (0.76em, on the baseline) + \"emocn\" in Manrope 600. */\nfunction RemocnLogo({ fontSize, color }: { fontSize: number; color: string }) {\n  return (\n    <div\n      style={{\n        display: \"flex\",\n        alignItems: \"baseline\",\n        color,\n        fontFamily: manrope.fontFamily,\n        fontWeight: 600,\n        fontSize,\n        letterSpacing: \"-0.025em\",\n        lineHeight: 1,\n      }}\n    >\n      <svg\n        viewBox=\"0 0 124.06 134.26\"\n        style={{ height: \"0.76em\", width: \"auto\" }}\n        aria-hidden\n      >\n        <path fill=\"currentColor\" d={REMOCN_R_PATH} />\n      </svg>\n      <span style={{ marginLeft: \"0.04em\" }}>emocn</span>\n    </div>\n  );\n}\n\ntype Pt = [number, number];\ntype Side = \"n\" | \"s\" | \"e\" | \"w\";\ntype Tone = \"accent\" | \"a\" | \"b\" | \"c\";\n\nexport interface TransitLine {\n  tone: Tone;\n  points: Pt[];\n  stations?: Pt[];\n  labels?: { at: Pt; text: string; side: Side }[];\n}\n\nexport interface TransitLinesProps {\n  title?: string;\n  kicker?: string;\n  lines?: TransitLine[];\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst DEFAULT_LINES: TransitLine[] = [\n  {\n    tone: \"accent\",\n    points: [\n      [-12, 3],\n      [-8, 3],\n      [-5, 0],\n      [0, 0],\n    ],\n    stations: [\n      [-10, 3],\n      [-2.7, 0],\n    ],\n    labels: [{ at: [-12, 3], text: \"Alder\", side: \"w\" }],\n  },\n  {\n    tone: \"a\",\n    points: [\n      [7, -6.5],\n      [3.5, -6.5],\n      [0, -3],\n      [0, 0],\n      [0, 6.3],\n    ],\n    stations: [\n      [1.75, -4.75],\n      [0, 3.4],\n    ],\n    labels: [\n      { at: [7, -6.5], text: \"Quay\", side: \"e\" },\n      { at: [0, 6.3], text: \"Kiln\", side: \"e\" },\n    ],\n  },\n  {\n    tone: \"b\",\n    points: [\n      [-10, -6],\n      [-6, -6],\n      [0, 0],\n      [4, 4],\n      [13.5, 4],\n    ],\n    stations: [\n      [-8, -6],\n      [-3.2, -3.2],\n      [7.5, 4],\n      [10.5, 4],\n    ],\n    labels: [\n      { at: [-10, -6], text: \"Fenwick\", side: \"w\" },\n      { at: [13.5, 4], text: \"Larch\", side: \"e\" },\n    ],\n  },\n  {\n    tone: \"c\",\n    points: [\n      [-11, 5.5],\n      [-5.5, 5.5],\n      [0, 0],\n      [4, -4],\n      [13.5, -4],\n    ],\n    stations: [\n      [-8.2, 5.5],\n      [-2.6, 2.6],\n      [7.5, -4],\n      [10.5, -4],\n    ],\n    labels: [\n      { at: [-11, 5.5], text: \"Holm\", side: \"w\" },\n      { at: [13.5, -4], text: \"Vale\", side: \"e\" },\n    ],\n  },\n];\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst DRAW_EASE = Easing.bezier(0.42, 0, 0.22, 1);\n\nconst DRAW_LEN = 38;\nconst ARRIVE = 48;\nconst LINE_GAP = 4;\n\nconst THEMES = {\n  dark: {\n    bg: \"#0A0A0B\",\n    fg: \"#F3F2EF\",\n    muted: \"#8B8A90\",\n    tones: { a: \"#E4E2DC\", b: \"#6C6B71\", c: \"#D49B57\" },\n    station: \"#FFFFFF\",\n    hair: \"#0A0A0B\",\n    hairW: 2.2,\n    ring: \"#FFFFFF\",\n    ringFill: \"#0A0A0B\",\n    glow: \"rgba(61,91,255,0.2)\",\n    glow2: \"rgba(143,180,255,0.07)\",\n    grain: 0.055,\n  },\n  light: {\n    bg: \"#F5F4F0\",\n    fg: \"#121214\",\n    muted: \"#6E6C67\",\n    tones: { a: \"#2B2A28\", b: \"#A9A69F\", c: \"#C9893A\" },\n    station: \"#FFFFFF\",\n    hair: \"rgba(18,18,20,0.55)\",\n    hairW: 1.3,\n    ring: \"#121214\",\n    ringFill: \"#FFFFFF\",\n    glow: \"rgba(61,91,255,0.08)\",\n    glow2: \"rgba(143,180,255,0.1)\",\n    grain: 0.04,\n  },\n} as const;\n\nfunction roundedPath(pts: Pt[], radius: number) {\n  let d = `M${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)}`;\n  for (let i = 1; i < pts.length - 1; i++) {\n    const [px, py] = pts[i - 1];\n    const [x, y] = pts[i];\n    const [nx, ny] = pts[i + 1];\n    const l1 = Math.hypot(x - px, y - py) || 1;\n    const l2 = Math.hypot(nx - x, ny - y) || 1;\n    const r = Math.min(radius, l1 / 2, l2 / 2);\n    const ax = x - ((x - px) / l1) * r;\n    const ay = y - ((y - py) / l1) * r;\n    const bx = x + ((nx - x) / l2) * r;\n    const by = y + ((ny - y) / l2) * r;\n    d += ` L${ax.toFixed(2)} ${ay.toFixed(2)} Q${x.toFixed(2)} ${y.toFixed(2)} ${bx.toFixed(2)} ${by.toFixed(2)}`;\n  }\n  const last = pts[pts.length - 1];\n  d += ` L${last[0].toFixed(2)} ${last[1].toFixed(2)}`;\n  return d;\n}\n\nfunction alongPoly(pts: Pt[], q: Pt) {\n  let run = 0;\n  let best = Number.POSITIVE_INFINITY;\n  let at = -1;\n  for (let i = 1; i < pts.length; i++) {\n    const [ax, ay] = pts[i - 1];\n    const [bx, by] = pts[i];\n    const len = Math.hypot(bx - ax, by - ay) || 1;\n    const k = Math.max(\n      0,\n      Math.min(\n        1,\n        ((q[0] - ax) * (bx - ax) + (q[1] - ay) * (by - ay)) / len ** 2,\n      ),\n    );\n    const dd = Math.hypot(ax + (bx - ax) * k - q[0], ay + (by - ay) * k - q[1]);\n    if (dd < best) {\n      best = dd;\n      at = run + k * len;\n    }\n    run += len;\n  }\n  return best < 0.05 ? at / Math.max(1e-6, run) : -1;\n}\n\nfunction reachFrame(start: number, f: number) {\n  if (f <= 0) return start;\n  for (let fr = start; fr <= start + DRAW_LEN; fr += 0.5) {\n    if (\n      DRAW_EASE(interpolate(fr, [start, start + DRAW_LEN], [0, 1], CLAMP)) >= f\n    )\n      return fr;\n  }\n  return start + DRAW_LEN;\n}\n\nfunction textW(text: string, size: number) {\n  return text.length * size * 0.5;\n}\n\nfunction Grain({ opacity }: { opacity: number }) {\n  const id = `g${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg\n      width=\"100%\"\n      height=\"100%\"\n      style={{ position: \"absolute\", inset: 0, opacity, pointerEvents: \"none\" }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.9\"\n          numOctaves={2}\n          seed={31}\n          stitchTiles=\"stitch\"\n        />\n        <feColorMatrix values=\"0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0\" />\n      </filter>\n      <rect width=\"100%\" height=\"100%\" filter={`url(#${id})`} />\n    </svg>\n  );\n}\n\nexport function TransitLines({\n  title = \"remocn\",\n  kicker = \"Next stop\",\n  lines = DEFAULT_LINES,\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: TransitLinesProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H, fps } = useVideoConfig();\n  const u = Math.min(W / 1280, H / 720);\n  const p = THEMES[theme];\n  const color = (tone: Tone) => (tone === \"accent\" ? accent : p.tones[tone]);\n  const hubGradId = `hub${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const isLogo = title.trim().toLowerCase() === \"remocn\";\n\n  const labelSize = 17 * u;\n  const heroSize = 100 * u;\n  const kickerSize = 20 * u;\n\n  const fit = (g: number) => {\n    let minX = Number.POSITIVE_INFINITY;\n    let maxX = Number.NEGATIVE_INFINITY;\n    let minY = Number.POSITIVE_INFINITY;\n    let maxY = Number.NEGATIVE_INFINITY;\n    const add = (x: number, y: number) => {\n      minX = Math.min(minX, x);\n      maxX = Math.max(maxX, x);\n      minY = Math.min(minY, y);\n      maxY = Math.max(maxY, y);\n    };\n    for (const l of lines) {\n      for (const [x, y] of l.points) {\n        add(x * g - 0.4 * g, y * g - 0.4 * g);\n        add(x * g + 0.4 * g, y * g + 0.4 * g);\n      }\n      for (const lb of l.labels ?? []) {\n        const tw = textW(lb.text, labelSize);\n        const [x, y] = [lb.at[0] * g, lb.at[1] * g];\n        if (lb.side === \"e\") add(x + 0.62 * g + tw, y);\n        if (lb.side === \"w\") add(x - 0.62 * g - tw, y);\n        if (lb.side === \"n\") add(x, y - 0.6 * g - labelSize);\n        if (lb.side === \"s\") add(x, y + 0.6 * g + labelSize);\n      }\n    }\n    add(3.1 * g + (isLogo ? 3.62 * heroSize : textW(title, heroSize)), 0);\n    return { minX, maxX, minY, maxY };\n  };\n  let g = 40 * u;\n  let box = fit(g);\n  for (let i = 0; i < 40; i++) {\n    if (box.maxX - box.minX <= W * 0.86 && box.maxY - box.minY <= H * 0.84)\n      break;\n    g *= 0.98;\n    box = fit(g);\n  }\n  const hubX = W / 2 - (box.minX + box.maxX) / 2;\n  const hubY = H / 2 - (box.minY + box.maxY) / 2;\n  const X = (x: number) => hubX + x * g;\n  const Y = (y: number) => hubY + y * g;\n\n  const halves = lines.flatMap((l, li) => {\n    const hub = l.points.findIndex(([x, y]) => x === 0 && y === 0);\n    const segs: Pt[][] =\n      hub < 0\n        ? [l.points]\n        : [l.points.slice(0, hub + 1), l.points.slice(hub).reverse()].filter(\n            (s) => s.length > 1,\n          );\n    const arrive = ARRIVE + li * LINE_GAP;\n    return segs.map((pts) => ({\n      li,\n      tone: l.tone,\n      pts,\n      start: arrive - DRAW_LEN,\n      d: roundedPath(\n        pts.map(([x, y]): Pt => [X(x), Y(y)]),\n        g * 0.75,\n      ),\n    }));\n  });\n  const lastArrive = ARRIVE + (lines.length - 1) * LINE_GAP;\n  const GROW = lastArrive + 4;\n  const TITLE = GROW + 8;\n\n  const stationList = lines.flatMap((l, li) => {\n    const ends = [l.points[0], l.points[l.points.length - 1]].filter(\n      ([x, y]) => !(x === 0 && y === 0),\n    );\n    return [...ends, ...(l.stations ?? [])].map((s) => {\n      let at = Number.POSITIVE_INFINITY;\n      for (const h of halves) {\n        if (h.li !== li) continue;\n        const f = alongPoly(h.pts, s);\n        if (f >= 0) at = Math.min(at, reachFrame(h.start, f));\n      }\n      return { li, s, at: Number.isFinite(at) ? at : ARRIVE };\n    });\n  });\n\n  const lineW = g * 0.25;\n  const stR = g * 0.27;\n\n  const firstArrive = ARRIVE;\n  const hubPop = spring({\n    frame: t - firstArrive,\n    fps,\n    config: { damping: 14, stiffness: 200, mass: 0.6 },\n  });\n  const grow = spring({\n    frame: t - GROW,\n    fps,\n    config: { damping: 15, stiffness: 120, mass: 0.9 },\n  });\n  const hubR = (0.42 + 0.9 * grow) * g * (0.7 + 0.3 * hubPop);\n  const hubStroke = (0.13 + 0.07 * grow) * g;\n  const titleIn = interpolate(t, [TITLE, TITLE + 20], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const kickerIn = interpolate(t, [TITLE + 6, TITLE + 24], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n\n  const labels: React.ReactNode[] = [];\n  for (const l of lines) {\n    for (const lb of l.labels ?? []) {\n      const st = stationList.find(\n        (s) => s.s[0] === lb.at[0] && s.s[1] === lb.at[1],\n      );\n      const at = (st?.at ?? ARRIVE) + 4;\n      const a = interpolate(t, [at, at + 14], [0, 1], {\n        ...CLAMP,\n        easing: EASE_OUT,\n      });\n      const x = X(lb.at[0]);\n      const y = Y(lb.at[1]);\n      const off = 0.62 * g;\n      const shift = (1 - a) * 6 * u;\n      const base: React.CSSProperties = {\n        position: \"absolute\",\n        fontFamily: sans,\n        fontWeight: 500,\n        fontSize: labelSize,\n        lineHeight: 1,\n        letterSpacing: \"0\",\n        color: p.muted,\n        whiteSpace: \"nowrap\",\n        opacity: a,\n      };\n      const pos: React.CSSProperties =\n        lb.side === \"e\"\n          ? {\n              left: x + off,\n              top: y,\n              transform: `translate(${-shift}px, -50%)`,\n            }\n          : lb.side === \"w\"\n            ? {\n                right: W - x + off,\n                top: y,\n                transform: `translate(${shift}px, -50%)`,\n              }\n            : lb.side === \"n\"\n              ? {\n                  left: x,\n                  bottom: H - y + off,\n                  transform: `translate(-50%, ${shift}px)`,\n                }\n              : {\n                  left: x,\n                  top: y + off,\n                  transform: `translate(-50%, ${-shift}px)`,\n                };\n      labels.push(\n        <div\n          key={`${lb.text}-${lb.at[0]}-${lb.at[1]}`}\n          style={{ ...base, ...pos }}\n        >\n          {lb.text}\n        </div>,\n      );\n    }\n  }\n\n  const driftK = interpolate(t, [0, 135], [0, 1], CLAMP);\n\n  return (\n    <AbsoluteFill style={{ background: p.bg, overflow: \"hidden\" }}>\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: `radial-gradient(${16 * g}px ${11 * g}px at ${hubX - 2 * g + 3 * g * driftK}px ${hubY + 2 * g}px, ${p.glow} 0%, transparent 70%), radial-gradient(${14 * g}px ${9 * g}px at ${hubX + 9 * g}px ${hubY - 3 * g}px, ${p.glow2} 0%, transparent 70%)`,\n          opacity: grow,\n        }}\n      />\n      <svg\n        width={W}\n        height={H}\n        style={{ position: \"absolute\", inset: 0 }}\n        aria-hidden\n      >\n        {halves.map((h) => {\n          const k = DRAW_EASE(\n            interpolate(t, [h.start, h.start + DRAW_LEN], [0, 1], CLAMP),\n          );\n          if (k <= 0) return null;\n          const evo = evolvePath(k, h.d);\n          return (\n            <path\n              key={`${h.li}-${h.pts[0][0]}-${h.pts[0][1]}`}\n              d={h.d}\n              fill=\"none\"\n              stroke={color(h.tone)}\n              strokeWidth={lineW}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeDasharray={evo.strokeDasharray}\n              strokeDashoffset={evo.strokeDashoffset}\n            />\n          );\n        })}\n        {stationList.map((st) => {\n          if (t < st.at) return null;\n          const pop = spring({\n            frame: t - st.at,\n            fps,\n            config: { damping: 12, stiffness: 220, mass: 0.5 },\n          });\n          return (\n            <circle\n              key={`${st.li}-${st.s[0]}-${st.s[1]}`}\n              cx={X(st.s[0])}\n              cy={Y(st.s[1])}\n              r={stR * (0.55 + 0.45 * pop)}\n              fill={p.station}\n              stroke={p.hair}\n              strokeWidth={p.hairW * u}\n              opacity={Math.min(1, pop * 1.6)}\n            />\n          );\n        })}\n        <defs>\n          {/* gradients.sh \"beam\", set inside the interchange roundel. */}\n          <radialGradient\n            id={hubGradId}\n            cx={0.5}\n            cy={1.12 - 0.1 * driftK}\n            r={1.05}\n          >\n            <stop offset={0} stopColor=\"#f4f7ff\" />\n            <stop offset={0.26} stopColor=\"#8fb4ff\" />\n            <stop offset={0.62} stopColor={accent} />\n            <stop offset={1} stopColor=\"#2e3fd0\" />\n          </radialGradient>\n        </defs>\n        {t >= firstArrive ? (\n          <g opacity={Math.min(1, hubPop * 1.6)}>\n            <circle\n              cx={hubX}\n              cy={hubY}\n              r={hubR + hubStroke / 2 + p.hairW * u}\n              fill={p.hair}\n            />\n            <circle\n              cx={hubX}\n              cy={hubY}\n              r={hubR}\n              fill={p.ringFill}\n              stroke={p.ring}\n              strokeWidth={hubStroke}\n            />\n            <circle\n              cx={hubX}\n              cy={hubY}\n              r={Math.max(0, hubR - hubStroke / 2)}\n              fill={`url(#${hubGradId})`}\n              opacity={grow}\n            />\n          </g>\n        ) : null}\n      </svg>\n      {labels}\n      <div\n        style={{\n          position: \"absolute\",\n          left: hubX + 3.1 * g,\n          top: hubY,\n          transform: `translateY(calc(-50% - ${(kickerSize * 1.2 + 8 * u) / 2}px))`,\n          whiteSpace: \"nowrap\",\n        }}\n      >\n        <div\n          style={{\n            fontFamily: sans,\n            fontWeight: 400,\n            fontSize: kickerSize,\n            letterSpacing: \"0\",\n            color: p.muted,\n            marginBottom: 10 * u,\n            opacity: kickerIn,\n            transform: `translateY(${(1 - kickerIn) * 6 * u}px)`,\n          }}\n        >\n          {kicker}\n        </div>\n        <div\n          style={{\n            fontFamily: sans,\n            fontWeight: 500,\n            fontSize: heroSize,\n            lineHeight: 0.95,\n            letterSpacing: \"-0.035em\",\n            color: p.fg,\n            opacity: titleIn,\n            transform: `translateX(${(1 - titleIn) * -18 * u}px)`,\n            filter:\n              titleIn < 0.99 ? `blur(${(1 - titleIn) * 6 * u}px)` : undefined,\n          }}\n        >\n          {isLogo ? <RemocnLogo fontSize={heroSize} color={p.fg} /> : title}\n        </div>\n      </div>\n      <AbsoluteFill\n        style={{\n          background: `radial-gradient(ellipse 80% 80% at 50% 50%, transparent 62%, ${p.bg} 100%)`,\n          opacity: 0.5,\n          pointerEvents: \"none\",\n        }}\n      />\n      <Grain opacity={p.grain} />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/transit-lines.tsx"
    }
  ],
  "type": "registry:component"
}
