Domphy

globe

A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call globe() with no arguments for a working demo, or edit the code below live.

Implementation notes

Uses the cobe WebGL dot-globe library directly (an approved pre-installed dependency for this exact purpose, per the block-authoring brief) via its public createGlobe(canvas, options) API — real dot-sphere rendering, auto-rotate, drag-to-orbit with velocity-decay inertial coasting, lat/long markers. Canvas is created imperatively in _onMount (not a static Domphy child) to sidestep the parent-Mount-fires-before-children-render ordering gotcha confirmed in ElementNode's render() path. Default sphere/marker/glow colors resolve from the live Domphy theme via themeColorToken rather than guessed literal hex values. Gaps: (1) cobe bakes width/height into construction, so a meaningful container resize recreates the instance rather than mutating it in place; (2) WebGL init is wrapped in try/catch and fails closed to a static empty canvas in environments without a real WebGL context (confirmed via jsdom test, which has none) — visual WebGL rendering itself can't be verified under the jsdom test runtime, only the DOM scaffolding/lifecycle/cleanup around it.

Status: ported · Reference: Magic UI original

// magicui "Globe" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). An
// interactive, auto-rotating 3D dot-sphere globe, drag-to-orbit with
// inertial coasting, rendered on a `<canvas>` via WebGL.
//
// Rendering is delegated to `cobe` (already an approved dependency of this
// package, per the block-authoring brief — see the package's `dependencies`)
// rather than hand-rolling a sphere rasterizer: it is the standard
// lightweight WebGL dot-globe library and using its public `createGlobe(canvas,
// options)` API is a legitimate, independent integration, not a copy of any
// UI framework's component source. Default sphere/marker colors are resolved
// from the current Domphy theme (via `themeColorToken`) rather than guessed
// literal hex values, so the globe matches whatever theme is active.

import type { DomphyElement, ElementNode } from "@domphy/core";
import { type ThemeColor, themeColorToken, themeSpacing } from "@domphy/theme";
import createGlobe, { type COBEOptions, type Marker } from "cobe";

export interface GlobeMarker {
  latitude: number;
  longitude: number;
  /** Marker dot size, in cobe's own 0–1 scale. Defaults to 0.05. */
  size?: number;
  /** Normalized RGB triplet (0–1 per channel). Defaults to the globe's `markerColor`. */
  color?: [number, number, number];
}

export interface GlobeProps {
  /** Container max diameter, in `themeSpacing` units. Defaults to 90 (~22.5em). */
  diameterUnits?: number;
  /** cobe's own dark-mode shading flag (affects the lighting model, independent of the page theme). Defaults to false. */
  dark?: boolean;
  /** Normalized RGB triplet for the sphere's base/land color. Defaults to the theme's neutral "shift-3" token. */
  baseColor?: [number, number, number];
  /** Normalized RGB triplet for marker dots. Defaults to the theme's "attention" "shift-9" token. */
  markerColor?: [number, number, number];
  /** Normalized RGB triplet for the atmosphere glow. Defaults to the theme's neutral "shift-1" token. */
  glowColor?: [number, number, number];
  /** Dot sample density across the sphere surface. Defaults to 16000. */
  mapSamples?: number;
  /** Land-dot brightness. Defaults to 6. */
  mapBrightness?: number;
  /** Auto-rotation speed (phi radians added per frame). Defaults to 0.0035. */
  rotationSpeed?: number;
  /** Initial phi (longitude) rotation offset, radians. Defaults to 0. */
  initialPhi?: number;
  /** Initial theta (latitude) tilt, radians. Defaults to 0.3. */
  initialTheta?: number;
  /** Highlighted lat/long locations. Defaults to a handful of major-city reference points. */
  markers?: GlobeMarker[];
  /** Enables click-and-drag orbit control. Defaults to true. */
  draggable?: boolean;
}

// Well-known public city coordinates — plain geographic facts, used purely as
// illustrative default marker locations for the demo, not sourced from any
// third party's specific marker dataset.
const DEFAULT_MARKERS: GlobeMarker[] = [
  { latitude: 40.7128, longitude: -74.006, size: 0.05 },
  { latitude: 51.5074, longitude: -0.1278, size: 0.05 },
  { latitude: 35.6762, longitude: 139.6503, size: 0.05 },
  { latitude: -33.8688, longitude: 151.2093, size: 0.05 },
  { latitude: 1.3521, longitude: 103.8198, size: 0.05 },
];

function hexToNormalizedRgb(hex: string): [number, number, number] {
  const normalized = hex.replace("#", "");
  const r = Number.parseInt(normalized.slice(0, 2), 16) / 255;
  const g = Number.parseInt(normalized.slice(2, 4), 16) / 255;
  const b = Number.parseInt(normalized.slice(4, 6), 16) / 255;
  return [r || 0, g || 0, b || 0];
}

/**
 * An interactive auto-rotating dot-sphere globe (WebGL via `cobe`), with
 * drag-to-orbit and inertial coasting. Call with no arguments for a working
 * demo — a themed sphere with a handful of highlighted city markers,
 * auto-rotating at rest.
 */
function globe(props: GlobeProps = {}): DomphyElement<"div"> {
  const diameterUnits = props.diameterUnits ?? 90;
  const dark = props.dark ?? false;
  const mapSamples = props.mapSamples ?? 16000;
  const mapBrightness = props.mapBrightness ?? 6;
  const rotationSpeed = props.rotationSpeed ?? 0.0035;
  const initialPhi = props.initialPhi ?? 0;
  const initialTheta = props.initialTheta ?? 0.3;
  const markers = props.markers ?? DEFAULT_MARKERS;
  const draggable = props.draggable ?? true;

  return {
    div: [],
    role: "img",
    ariaLabel: "Interactive rotating globe",
    style: {
      position: "relative",
      width: "100%",
      maxWidth: themeSpacing(diameterUnits),
      aspectRatio: "1 / 1",
      marginInline: "auto",
      contain: "layout paint size",
    },
    _onMount: (node: ElementNode) => {
      const container = node.domElement as HTMLElement | null;
      if (!container || typeof document === "undefined") return;

      const canvas = document.createElement("canvas");
      canvas.setAttribute("aria-hidden", "true");
      canvas.style.position = "absolute";
      canvas.style.inset = "0";
      canvas.style.width = "100%";
      canvas.style.height = "100%";
      canvas.style.cursor = draggable ? "grab" : "default";
      container.appendChild(canvas);

      let phi = initialPhi;
      let velocity = 0;
      let pointerDown = false;
      let pointerStartX = 0;
      let phiAtPointerDown = 0;
      let width = container.clientWidth || 1;
      let globeInstance: ReturnType<typeof createGlobe> | null = null;
      let resizeObserver: ResizeObserver | null = null;

      const resolveColor = (
        override: [number, number, number] | undefined,
        tone: string,
        colorName: ThemeColor,
      ): [number, number, number] => {
        if (override) return override;
        try {
          return hexToNormalizedRgb(themeColorToken(node, tone, colorName));
        } catch {
          return [0.4, 0.4, 0.45];
        }
      };

      const markerList: Marker[] = markers.map((marker) => ({
        location: [marker.latitude, marker.longitude],
        size: marker.size ?? 0.05,
        color: marker.color,
      }));

      const baseColor = resolveColor(props.baseColor, "shift-3", "neutral");
      const markerColor = resolveColor(props.markerColor, "shift-9", "attention");
      const glowColor = resolveColor(props.glowColor, "shift-1", "neutral");

      const buildOptions = (): COBEOptions => ({
        devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2),
        width: width * 2,
        height: width * 2,
        phi,
        theta: initialTheta,
        dark: dark ? 1 : 0,
        diffuse: 1.2,
        mapSamples,
        mapBrightness,
        baseColor,
        markerColor,
        glowColor,
        markers: markerList,
        onRender: (state) => {
          if (!pointerDown) {
            if (Math.abs(velocity) > 0.0001) {
              phi += velocity;
              velocity *= 0.92;
            } else {
              phi += rotationSpeed;
            }
          }
          state.phi = phi;
        },
      });

      // cobe requires a real WebGL context; in environments without one
      // (older browsers, headless/test runtimes) initialization throws
      // synchronously — fail closed to a static empty canvas rather than
      // crashing the whole tree.
      try {
        globeInstance = createGlobe(canvas, buildOptions());
      } catch {
        globeInstance = null;
      }

      const handlePointerDown = (event: PointerEvent) => {
        if (!draggable) return;
        pointerDown = true;
        pointerStartX = event.clientX;
        phiAtPointerDown = phi;
        velocity = 0;
        canvas.style.cursor = "grabbing";
        try {
          canvas.setPointerCapture(event.pointerId);
        } catch {
          // Pointer capture is best-effort — unsupported/detached targets are fine to ignore.
        }
      };
      const handlePointerMove = (event: PointerEvent) => {
        if (!pointerDown) return;
        const delta = event.clientX - pointerStartX;
        const nextPhi = phiAtPointerDown + delta / 100;
        velocity = nextPhi - phi;
        phi = nextPhi;
      };
      const handlePointerUp = (event: PointerEvent) => {
        if (!pointerDown) return;
        pointerDown = false;
        canvas.style.cursor = "grab";
        try {
          canvas.releasePointerCapture(event.pointerId);
        } catch {
          // Best-effort release, as above.
        }
      };

      if (draggable) {
        canvas.addEventListener("pointerdown", handlePointerDown);
        window.addEventListener("pointermove", handlePointerMove);
        window.addEventListener("pointerup", handlePointerUp);
      }

      // cobe bakes width/height into its initial options rather than reading
      // them reactively every frame, so a meaningful container resize
      // recreates the instance (preserving the current `phi`/`velocity`
      // closures) instead of trying to mutate it in place.
      if (typeof ResizeObserver !== "undefined") {
        resizeObserver = new ResizeObserver(() => {
          const nextWidth = container.clientWidth;
          if (Math.abs(nextWidth - width) < 4 || nextWidth === 0) return;
          width = nextWidth;
          globeInstance?.destroy();
          try {
            globeInstance = createGlobe(canvas, buildOptions());
          } catch {
            globeInstance = null;
          }
        });
        resizeObserver.observe(container);
      }

      node.addHook("Remove", () => {
        globeInstance?.destroy();
        resizeObserver?.disconnect();
        if (draggable) {
          canvas.removeEventListener("pointerdown", handlePointerDown);
          window.removeEventListener("pointermove", handlePointerMove);
          window.removeEventListener("pointerup", handlePointerUp);
        }
      });
    },
  };
}

export { globe };

← Back to Magic UI catalog