{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "globe-3d",
  "type": "registry:component",
  "title": "Globe 3D",
  "description": "Standalone SVG pseudo-3D globe — orthographic projection with auto-rotation, drag interaction, lat/lng markers, and great-circle arcs.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/globe-3d/globe-3d.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  isValidElement,\n  type PointerEvent as ReactPointerEvent,\n  type ReactElement,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nconst VIEWBOX = 400;\nconst SPHERE_RADIUS = 180;\nconst CENTER = VIEWBOX / 2;\n\n/**\n * Geographic coordinate as `{ lat, lng }`.\n *\n * @public\n */\nexport type GlobeCoord = { lat: number; lng: number };\n\n/**\n * Color theme for markers and arcs.\n *\n * @public\n */\nexport type GlobeColor =\n  | \"amber\"\n  | \"blue\"\n  | \"cyan\"\n  | \"emerald\"\n  | \"purple\"\n  | \"red\"\n  | \"rose\";\n\nconst PALETTE: Record<GlobeColor, { fill: string; stroke: string }> = {\n  amber: { fill: \"fill-amber-500\", stroke: \"stroke-amber-500\" },\n  blue: { fill: \"fill-blue-500\", stroke: \"stroke-blue-500\" },\n  cyan: { fill: \"fill-cyan-500\", stroke: \"stroke-cyan-500\" },\n  emerald: { fill: \"fill-emerald-500\", stroke: \"stroke-emerald-500\" },\n  purple: { fill: \"fill-purple-500\", stroke: \"stroke-purple-500\" },\n  red: { fill: \"fill-red-500\", stroke: \"stroke-red-500\" },\n  rose: { fill: \"fill-rose-500\", stroke: \"stroke-rose-500\" },\n};\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type Globe3DLabels = {\n  /** Aria-label for the globe region. Defaults to `\"Globe\"`. */\n  region?: string;\n};\n\nconst DEFAULT_LABELS = {\n  region: \"Globe\",\n} as const satisfies Required<Globe3DLabels>;\n\ntype Vec3 = { x: number; y: number; z: number };\n\nfunction toRadians(degrees: number): number {\n  return (degrees * Math.PI) / 180;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction rotate(vector: Vec3, lngOffset: number, latOffset: number): Vec3 {\n  const yawRad = toRadians(lngOffset);\n  const pitchRad = toRadians(latOffset);\n  const cosY = Math.cos(yawRad);\n  const sinY = Math.sin(yawRad);\n  const xy = vector.x * cosY + vector.z * sinY;\n  const zy = -vector.x * sinY + vector.z * cosY;\n  const cosP = Math.cos(pitchRad);\n  const sinP = Math.sin(pitchRad);\n  const yp = vector.y * cosP - zy * sinP;\n  const zp = vector.y * sinP + zy * cosP;\n  return { x: xy, y: yp, z: zp };\n}\n\nfunction latLngToVector({ lat, lng }: GlobeCoord): Vec3 {\n  const phi = toRadians(90 - lat);\n  const theta = toRadians(lng);\n  return {\n    x: Math.sin(phi) * Math.sin(theta),\n    y: Math.cos(phi),\n    z: Math.sin(phi) * Math.cos(theta),\n  };\n}\n\nfunction project(\n  coord: GlobeCoord,\n  rotationLng: number,\n  rotationLat: number,\n): { visible: boolean; x: number; y: number; z: number } {\n  const baseVector = latLngToVector(coord);\n  const rotated = rotate(baseVector, rotationLng, rotationLat);\n  return {\n    visible: rotated.z >= 0,\n    x: CENTER + rotated.x * SPHERE_RADIUS,\n    y: CENTER - rotated.y * SPHERE_RADIUS,\n    z: rotated.z,\n  };\n}\n\ntype Ctx = {\n  rotationLat: number;\n  rotationLng: number;\n};\n\nconst GlobeContext = createContext<Ctx | null>(null);\n\nfunction useGlobeContext(): Ctx {\n  const ctx = useContext(GlobeContext);\n  if (!ctx) {\n    throw new Error(\"Globe3D subcomponent used outside its root.\");\n  }\n  return ctx;\n}\n\n/**\n * Props for {@link GlobeMarker}.\n *\n * @public\n */\nexport type GlobeMarkerProps = {\n  /** Color theme. Defaults to `\"red\"`. */\n  color?: GlobeColor;\n  /** Stable id used for analytics + React keys. */\n  id?: string;\n  /** Optional label rendered next to the marker. */\n  label?: ReactNode;\n  /** Latitude. Positive = north. */\n  lat: number;\n  /** Longitude. Positive = east. */\n  lng: number;\n} & Omit<ComponentPropsWithoutRef<\"g\">, \"id\">;\n\n/**\n * Point marker pinned to a `[lat, lng]`. Hidden when the point falls on\n * the far side of the globe.\n *\n * @public\n */\nexport const GlobeMarker = forwardRef<SVGGElement, GlobeMarkerProps>(\n  (props, ref) => {\n    const { color = \"red\", id, label, lat, lng, ...rest } = props;\n    const { rotationLat, rotationLng } = useGlobeContext();\n    const projected = project({ lat, lng }, rotationLng, rotationLat);\n    if (!projected.visible) return null;\n    const palette = PALETTE[color];\n    return (\n      <g\n        data-marker-id={id}\n        data-marker-visible=\"true\"\n        ref={ref}\n        transform={`translate(${projected.x.toString()}, ${projected.y.toString()})`}\n        {...rest}\n      >\n        <circle\n          className={cn(\"stroke-background\", palette.fill)}\n          r=\"5\"\n          strokeWidth=\"1.5\"\n        />\n        {label ? (\n          <text\n            className=\"select-none fill-foreground text-[10px] font-semibold\"\n            dominantBaseline=\"middle\"\n            textAnchor=\"middle\"\n            y=\"-10\"\n          >\n            {label}\n          </text>\n        ) : null}\n      </g>\n    );\n  },\n);\nGlobeMarker.displayName = \"GlobeMarker\";\n\n/**\n * Props for {@link GlobeArc}.\n *\n * @public\n */\nexport type GlobeArcProps = {\n  /** Color theme. Defaults to `\"cyan\"`. */\n  color?: GlobeColor;\n  /** Origin coordinate. */\n  from: GlobeCoord;\n  /** Stable id used for analytics + React keys. */\n  id?: string;\n  /** Stroke width in viewBox units. Defaults to `2`. */\n  strokeWidth?: number;\n  /** Destination coordinate. */\n  to: GlobeCoord;\n} & Omit<ComponentPropsWithoutRef<\"path\">, \"d\" | \"id\" | \"stroke\">;\n\nfunction buildArc(arguments_: {\n  from: GlobeCoord;\n  rotationLat: number;\n  rotationLng: number;\n  to: GlobeCoord;\n}): string {\n  const { from, rotationLat, rotationLng, to } = arguments_;\n  const samples = 36;\n  return Array.from({ length: samples + 1 })\n    .map((_, index) => {\n      const t = index / samples;\n      const lat = from.lat + (to.lat - from.lat) * t;\n      const lng = from.lng + (to.lng - from.lng) * t;\n      return project({ lat, lng }, rotationLng, rotationLat);\n    })\n    .reduce<{ path: string; pen: \"down\" | \"up\" }>(\n      (state, projected) => {\n        if (!projected.visible) return { path: state.path, pen: \"up\" };\n        const head = state.pen === \"up\" ? \"M\" : \"L\";\n        const separator = state.path.length > 0 ? \" \" : \"\";\n        return {\n          path: `${state.path}${separator}${head}${projected.x.toString()},${projected.y.toString()}`,\n          pen: \"down\",\n        };\n      },\n      { path: \"\", pen: \"up\" },\n    ).path;\n}\n\n/**\n * Great-circle-style arc between two coordinates. The component clips\n * any segment on the far side of the globe.\n *\n * @public\n */\nexport const GlobeArc = forwardRef<SVGPathElement, GlobeArcProps>(\n  (props, ref) => {\n    const { color = \"cyan\", from, id, strokeWidth = 2, to, ...rest } = props;\n    const { rotationLat, rotationLng } = useGlobeContext();\n    const path = buildArc({ from, rotationLat, rotationLng, to });\n    if (!path) return null;\n    const palette = PALETTE[color];\n    return (\n      <path\n        className={cn(\"fill-none\", palette.stroke)}\n        d={path}\n        data-arc-id={id}\n        ref={ref}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth={strokeWidth}\n        {...rest}\n      />\n    );\n  },\n);\nGlobeArc.displayName = \"GlobeArc\";\n\ntype GraticuleProps = {\n  rotationLat: number;\n  rotationLng: number;\n};\n\nfunction buildLine(arguments_: {\n  points: GlobeCoord[];\n  rotationLat: number;\n  rotationLng: number;\n}): string {\n  const { points, rotationLat, rotationLng } = arguments_;\n  return points\n    .map((coord) => project(coord, rotationLng, rotationLat))\n    .reduce<{ path: string; pen: \"down\" | \"up\" }>(\n      (state, projected) => {\n        if (!projected.visible) return { path: state.path, pen: \"up\" };\n        const head = state.pen === \"up\" ? \"M\" : \"L\";\n        const separator = state.path.length > 0 ? \" \" : \"\";\n        return {\n          path: `${state.path}${separator}${head}${projected.x.toString()},${projected.y.toString()}`,\n          pen: \"down\",\n        };\n      },\n      { path: \"\", pen: \"up\" },\n    ).path;\n}\n\nfunction range(start: number, end: number, step: number): number[] {\n  const length = Math.floor((end - start) / step) + 1;\n  return Array.from({ length }).map((_, index) => start + index * step);\n}\n\nfunction Graticule({ rotationLat, rotationLng }: GraticuleProps): ReactNode {\n  const parallels = range(-60, 60, 30).map<GlobeCoord[]>((lat) =>\n    range(-180, 180, 5).map((lng) => ({ lat, lng })),\n  );\n  const meridians = range(-150, 180, 30).map<GlobeCoord[]>((lng) =>\n    range(-85, 85, 5).map((lat) => ({ lat, lng })),\n  );\n  const lines = [...parallels, ...meridians]\n    .map((points) => buildLine({ points, rotationLat, rotationLng }))\n    .filter((path) => path.length > 0);\n  return (\n    <g\n      className=\"fill-none stroke-foreground/20\"\n      data-globe-graticule\n      strokeWidth={0.5}\n    >\n      {lines.map((path) => (\n        <path d={path} key={path} />\n      ))}\n    </g>\n  );\n}\n\ntype SphereProps = {\n  children: ReactNode;\n  rotationLat: number;\n  rotationLng: number;\n};\n\nfunction Sphere({\n  children,\n  rotationLat,\n  rotationLng,\n}: SphereProps): ReactNode {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"block h-full w-full touch-none\"\n      data-rotation-lat={rotationLat}\n      data-rotation-lng={rotationLng}\n      preserveAspectRatio=\"xMidYMid meet\"\n      viewBox={`0 0 ${VIEWBOX.toString()} ${VIEWBOX.toString()}`}\n    >\n      <defs>\n        <radialGradient cx=\"50%\" cy=\"40%\" id=\"globe-shade\" r=\"60%\">\n          <stop offset=\"0%\" stopColor=\"rgba(99, 102, 241, 0.4)\" />\n          <stop offset=\"100%\" stopColor=\"rgba(15, 23, 42, 0.85)\" />\n        </radialGradient>\n      </defs>\n      <circle\n        className=\"fill-[url(#globe-shade)] stroke-foreground/30\"\n        cx={CENTER}\n        cy={CENTER}\n        data-globe-sphere\n        r={SPHERE_RADIUS}\n        strokeWidth={1}\n      />\n      <Graticule rotationLat={rotationLat} rotationLng={rotationLng} />\n      {children}\n    </svg>\n  );\n}\n\n/**\n * Props for {@link Globe3D}.\n *\n * @public\n */\nexport type Globe3DProps = {\n  /** When `true`, the globe rotates on its own. Defaults to `true`. */\n  autoRotate?: boolean;\n  /** Initial view position. */\n  initialPosition?: GlobeCoord;\n  /** Localizable strings. */\n  labels?: Globe3DLabels;\n  /** Auto-rotation rate in degrees per second. Defaults to `8`. */\n  rotationSpeed?: number;\n} & ComponentPropsWithoutRef<\"section\">;\n\nfunction useAutoRotation(arguments_: {\n  autoRotate: boolean;\n  isDragging: boolean;\n  rotationSpeed: number;\n  setRotationLng: (next: (current: number) => number) => void;\n}): void {\n  const { autoRotate, isDragging, rotationSpeed, setRotationLng } = arguments_;\n  useEffect(() => {\n    if (!autoRotate || isDragging) return;\n    if (typeof window === \"undefined\") return;\n    let frame = 0;\n    let last: null | number = null;\n    const step = (timestamp: number): void => {\n      if (last !== null) {\n        const delta = (timestamp - last) / 1000;\n        setRotationLng((current) => current + delta * rotationSpeed);\n      }\n      last = timestamp;\n      frame = window.requestAnimationFrame(step);\n    };\n    frame = window.requestAnimationFrame(step);\n    return () => {\n      window.cancelAnimationFrame(frame);\n    };\n  }, [autoRotate, isDragging, rotationSpeed, setRotationLng]);\n}\n\ntype DragState = null | {\n  originLat: number;\n  originLng: number;\n  originX: number;\n  originY: number;\n};\n\ntype DragHandlers = {\n  onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;\n};\n\nfunction useDragRotation(arguments_: {\n  rotationLat: number;\n  rotationLng: number;\n  setIsDragging: (next: boolean) => void;\n  setRotationLat: (next: number) => void;\n  setRotationLng: (next: number) => void;\n}): DragHandlers {\n  const {\n    rotationLat,\n    rotationLng,\n    setIsDragging,\n    setRotationLat,\n    setRotationLng,\n  } = arguments_;\n  const dragRef = useRef<DragState>(null);\n\n  const onPointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>): void => {\n      dragRef.current = {\n        originLat: rotationLat,\n        originLng: rotationLng,\n        originX: event.clientX,\n        originY: event.clientY,\n      };\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setIsDragging(true);\n    },\n    [rotationLat, rotationLng, setIsDragging],\n  );\n\n  const onPointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>): void => {\n      const drag = dragRef.current;\n      if (!drag) return;\n      const dx = event.clientX - drag.originX;\n      const dy = event.clientY - drag.originY;\n      setRotationLng(drag.originLng + dx * 0.4);\n      setRotationLat(clamp(drag.originLat - dy * 0.4, -85, 85));\n    },\n    [setRotationLat, setRotationLng],\n  );\n\n  const onPointerEnd = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>): void => {\n      const target = event.currentTarget;\n      if (target.hasPointerCapture(event.pointerId)) {\n        target.releasePointerCapture(event.pointerId);\n      }\n      dragRef.current = null;\n      setIsDragging(false);\n    },\n    [setIsDragging],\n  );\n\n  return {\n    onPointerCancel: onPointerEnd,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp: onPointerEnd,\n  };\n}\n\nfunction useGlobeRotation(initialPosition: GlobeCoord): {\n  isDragging: boolean;\n  rotationLat: number;\n  rotationLng: number;\n  setIsDragging: (next: boolean) => void;\n  setRotationLat: (next: number) => void;\n  setRotationLng: (next: ((current: number) => number) | number) => void;\n} {\n  const [rotationLng, setRotationLng] = useState<number>(-initialPosition.lng);\n  const [rotationLat, setRotationLat] = useState<number>(initialPosition.lat);\n  const [isDragging, setIsDragging] = useState(false);\n  return {\n    isDragging,\n    rotationLat,\n    rotationLng,\n    setIsDragging,\n    setRotationLat,\n    setRotationLng,\n  };\n}\n\ntype DataSummaryProps = {\n  children: ReactNode;\n  titleId: string;\n};\n\nfunction DataSummary({ children, titleId }: DataSummaryProps): ReactNode {\n  const markers: {\n    color: string;\n    label: ReactNode;\n    lat: number;\n    lng: number;\n  }[] = [];\n  Children.forEach(children, (child) => {\n    if (!isValidElement(child)) return;\n    const element = child as ReactElement<GlobeMarkerProps>;\n    if (\n      typeof element.props.lat === \"number\" &&\n      typeof element.props.lng === \"number\"\n    ) {\n      markers.push({\n        color: element.props.color ?? \"red\",\n        label: element.props.label ?? null,\n        lat: element.props.lat,\n        lng: element.props.lng,\n      });\n    }\n  });\n  return (\n    <div aria-labelledby={titleId} className=\"sr-only\" role=\"region\">\n      <h3 id={titleId}>Globe data summary</h3>\n      <p>{markers.length.toString()} marker(s) plotted on the globe.</p>\n      <ul>\n        {markers.map((marker, index) => (\n          <li\n            key={`${index.toString()}-${marker.lat.toString()}-${marker.lng.toString()}`}\n          >\n            {`${marker.lat.toString()}, ${marker.lng.toString()}: ${marker.label ? String(marker.label) : marker.color}`}\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\n/**\n * Standalone SVG pseudo-3D globe. Renders a unit sphere with\n * orthographic projection, a graticule (lat/lng lines), and any\n * {@link GlobeMarker} / {@link GlobeArc} children. Auto-rotates around\n * the Y axis; drag to rotate manually (auto-rotation pauses while\n * dragging). No external 3D library — pure SVG + React state.\n *\n * @example\n * ```tsx\n * <Globe3D autoRotate>\n *   <GlobeMarker lat={48.85} lng={2.35} label=\"Paris\" color=\"blue\" />\n *   <GlobeMarker lat={40.71} lng={-74} label=\"New York\" color=\"red\" />\n *   <GlobeArc\n *     from={{ lat: 48.85, lng: 2.35 }}\n *     to={{ lat: 40.71, lng: -74 }}\n *     color=\"cyan\"\n *   />\n * </Globe3D>\n * ```\n *\n * @public\n */\nexport const Globe3D = forwardRef<HTMLElement, Globe3DProps>((props, ref) => {\n  const {\n    autoRotate = true,\n    children,\n    className,\n    initialPosition = { lat: 20, lng: 0 },\n    labels,\n    rotationSpeed = 8,\n    ...rest\n  } = props;\n  const titleId = useId();\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n  const {\n    isDragging,\n    rotationLat,\n    rotationLng,\n    setIsDragging,\n    setRotationLat,\n    setRotationLng,\n  } = useGlobeRotation(initialPosition);\n\n  useAutoRotation({ autoRotate, isDragging, rotationSpeed, setRotationLng });\n\n  const handlers = useDragRotation({\n    rotationLat,\n    rotationLng,\n    setIsDragging,\n    setRotationLat,\n    setRotationLng: (next) => {\n      setRotationLng(next);\n    },\n  });\n\n  const ctx = useMemo<Ctx>(\n    () => ({ rotationLat, rotationLng }),\n    [rotationLat, rotationLng],\n  );\n\n  return (\n    <GlobeContext.Provider value={ctx}>\n      <section\n        aria-labelledby={titleId}\n        className={cn(\n          \"relative aspect-square w-full max-w-md overflow-hidden rounded-2xl border bg-background text-foreground\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        <span className=\"sr-only\" id={titleId}>\n          {resolvedLabels.region}\n        </span>\n        <div\n          className=\"block h-full w-full cursor-grab active:cursor-grabbing\"\n          data-globe-stage\n          {...handlers}\n        >\n          <Sphere rotationLat={rotationLat} rotationLng={rotationLng}>\n            {children}\n          </Sphere>\n        </div>\n        <DataSummary titleId={titleId}>{children}</DataSummary>\n      </section>\n    </GlobeContext.Provider>\n  );\n});\nGlobe3D.displayName = \"Globe3D\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
