Domphy

globe3D

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

Implementation notes

Genuinely functional real-time WebGL1 pipeline (hand-rolled — cobe, this package's other dependency, only renders the separate dot-matrix globe style and has no textured-sphere API, and three.js/a scene-graph library isn't in this package's pre-approved dependency list): a generated lat/long sphere mesh, diffuse + fresnel-rim 'atmosphere' shader, manual mat4 camera math, drag-to-orbit, wheel-to-zoom (clamped), continuous auto-rotate that pauses while dragging, and per-frame marker re-projection to 2D screen space driving avatar-image DOM markers + a hover tooltip. Marked 'partial' for two honest, spec-relevant gaps: (1) the default texture (used whenever baseTextureUrl isn't supplied) is a procedurally-painted ocean+blob-continent placeholder, not a real photographic/DEM Earth texture — the spec explicitly wants a 'photographic' read, which needs a real texture asset this clean-room, zero-network-by-default component doesn't ship; real texture/bump URLs ARE supported via props and swap in correctly when provided. (2) the 'bump map' is a diffuse-shading multiplier (bump texture's red channel darkens/brightens the lit hemisphere), not true per-pixel normal perturbation — WebGL1 core has no derivatives without the OES_standard_derivatives extension. Both gaps and the reasoning are documented in the file's own header comment. All interaction (orbit/zoom/markers/tooltip/auto-rotate) and the lighting/atmosphere shader are fully real, not stubbed — only visual texture fidelity is capped below 'photographic'.

Status: partial · Reference: Aceternity UI original

// Aceternity UI "3D Globe" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// realistically textured, slowly auto-rotating Earth rendered in real-time
// WebGL, with drag-to-orbit, wheel-to-zoom, and avatar-image markers pinned
// at given coordinates.
//
// Per the task's own researchNote, this is the "photographic texture +
// bump map" globe variant — distinct from this package's OWN
// `magicui/core/globe.ts`, which ports the separate dot-matrix globe by
// delegating to the `cobe` library. `cobe`'s public API only renders that
// dot/point style; it has no textured-sphere-with-lighting mode, so it
// cannot render this spec. Rather than reach for a new 3D dependency (not
// pre-approved for this package — see the task's own dependency list), this
// hand-rolls a small, self-contained WebGL1 pipeline: a generated
// lat/long sphere mesh, a Phong-ish lighting shader with a fresnel-rim
// "atmosphere" term, and manual mat4 camera math (perspective + a
// pure-rotation model matrix) — all standard, textbook real-time-graphics
// technique, not sourced from any specific engine or library.
//
// FIDELITY NOTES (honest gaps, since "textured + bump map" implies more
// than this can promise without a real DEM/heightmap asset or a WebGL1
// derivatives extension):
//  - The default Earth-like texture (used whenever `baseTextureUrl` isn't
//    supplied) is procedurally painted onto an offscreen 2D canvas — an
//    ocean gradient plus a handful of deterministic blob "continents" — not
//    a real satellite/DEM photograph. Real photographic fidelity would
//    require shipping (or fetching) an actual Earth texture asset, which is
//    outside a clean-room, zero-network-by-default component.
//  - The "bump map" is a shading MULTIPLIER (the bump texture's red channel
//    scales the diffuse term per-pixel, "embossing" the lit hemisphere) —
//    not true per-pixel normal perturbation. WebGL1 core has no
//    `dFdx`/`dFdy` without the `OES_standard_derivatives` extension, and a
//    real normal map needs actual tangent-space data this procedural
//    texture doesn't have; the multiplier approach reads as embossed
//    terrain from a normal viewing distance without either.
//  - Orbit "drag to rotate" rotates the SPHERE (model matrix) rather than
//    moving the camera around a fixed sphere — visually identical for a
//    single centered object with no other scene geometry, and it's the same
//    trick this package's own `magicui/core/globe.ts` (cobe) and most
//    single-object 3D viewers use.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { avatar } from "@domphy/ui";
import { type ThemeColor, themeColor, themeColorToken, themeSpacing } from "@domphy/theme";

export interface Globe3DMarker {
  latitude: number;
  longitude: number;
  /** Short label shown in the hover tooltip. */
  label?: string;
  /** Avatar image URL. When omitted, `initials` render inside a themed circle instead. */
  avatarUrl?: string;
  /** Fallback initials shown when `avatarUrl` is omitted. Defaults to `"•"`. */
  initials?: string;
  /** Marker avatar color family. Defaults to `"primary"`. */
  color?: ThemeColor;
}

export interface Globe3DProps {
  /** Pinned marker locations. Defaults to a handful of major-city reference points. */
  markers?: Globe3DMarker[];
  /** Container max diameter, in `themeSpacing` units. Defaults to `100` (~25em). */
  diameterUnits?: number;
  /** Real Earth-texture image URL. When omitted, a procedural placeholder texture is generated instead. */
  baseTextureUrl?: string;
  /** Bump/height-map image URL (red channel used as a diffuse-shading multiplier). When omitted, a procedural placeholder is generated instead. */
  bumpTextureUrl?: string;
  /** Ambient (unlit-side) light floor, 0-1. Defaults to `0.5`. */
  ambientIntensity?: number;
  /** Directional light strength, 0-1+. Defaults to `0.9`. */
  lightIntensity?: number;
  /** Directional light vector (world space, points FROM the surface TOWARD the light). Defaults to `[0.6, 0.5, 0.7]`. */
  lightDirection?: [number, number, number];
  /** Theme color family for the fresnel-rim atmosphere glow. Defaults to `"info"`. */
  atmosphereColor?: ThemeColor;
  /** Atmosphere glow strength, 0-1+. Defaults to `0.9`. */
  atmosphereIntensity?: number;
  /** Continuous idle auto-rotation. Defaults to `true`. */
  autoRotate?: boolean;
  /** Auto-rotation speed, radians/frame. Defaults to `0.0016`. */
  rotationSpeed?: number;
  /** Closest allowed camera distance (zoom in limit). Defaults to `1.7`. */
  minZoomDistance?: number;
  /** Farthest allowed camera distance (zoom out limit). Defaults to `4.5`. */
  maxZoomDistance?: number;
  /** Starting camera distance. Defaults to `2.6`. */
  initialZoomDistance?: number;
  /** Renders the raw triangle mesh (debug aid) instead of the shaded sphere. Defaults to `false`. */
  wireframe?: boolean;
  /** Fired when a marker gains/loses hover (`null` on loses-hover). */
  onMarkerHover?: (marker: Globe3DMarker | null) => void;
  /** Fired when a marker is clicked. */
  onMarkerClick?: (marker: Globe3DMarker, event: PointerEvent) => void;
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

// Well-known public city coordinates — plain geographic facts, used purely
// as illustrative default marker locations, not sourced from any third
// party's specific marker dataset (same idiom this package's own
// `magicui/core/globe.ts` uses for its own defaults).
const DEFAULT_MARKERS: Globe3DMarker[] = [
  { latitude: 40.7128, longitude: -74.006, initials: "NY", label: "New York", color: "primary" },
  { latitude: 51.5074, longitude: -0.1278, initials: "LN", label: "London", color: "info" },
  { latitude: 35.6762, longitude: 139.6503, initials: "TK", label: "Tokyo", color: "success" },
  { latitude: -33.8688, longitude: 151.2093, initials: "SY", label: "Sydney", color: "warning" },
  { latitude: 1.3521, longitude: 103.8198, initials: "SG", label: "Singapore", color: "secondary" },
];

const LATITUDE_SEGMENTS = 24;
const LONGITUDE_SEGMENTS = 48;
const TEXTURE_SIZE = 512;

// ---------------------------------------------------------------------------
// mat4 — minimal column-major 4x4 matrix helpers. Standard textbook
// real-time-graphics math (identical in form to gl-matrix/three.js's own
// perspective/rotation formulas), hand-written here rather than adding a
// matrix-math dependency for four small functions.
// ---------------------------------------------------------------------------

type Mat4 = Float32Array;

function mat4Identity(): Mat4 {
  return Float32Array.from([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
}

function mat4Multiply(a: Mat4, b: Mat4): Mat4 {
  const out = new Float32Array(16);
  for (let column = 0; column < 4; column++) {
    for (let row = 0; row < 4; row++) {
      let sum = 0;
      for (let k = 0; k < 4; k++) sum += a[k * 4 + row] * b[column * 4 + k];
      out[column * 4 + row] = sum;
    }
  }
  return out;
}

function mat4Perspective(fovYRadians: number, aspect: number, near: number, far: number): Mat4 {
  const focalLength = 1 / Math.tan(fovYRadians / 2);
  const out = new Float32Array(16);
  out[0] = focalLength / aspect;
  out[5] = focalLength;
  out[10] = (far + near) / (near - far);
  out[11] = -1;
  out[14] = (2 * far * near) / (near - far);
  return out;
}

function mat4Translation(x: number, y: number, z: number): Mat4 {
  const out = mat4Identity();
  out[12] = x;
  out[13] = y;
  out[14] = z;
  return out;
}

function mat4RotationX(radians: number): Mat4 {
  const out = mat4Identity();
  const cos = Math.cos(radians);
  const sin = Math.sin(radians);
  out[5] = cos;
  out[6] = sin;
  out[9] = -sin;
  out[10] = cos;
  return out;
}

function mat4RotationY(radians: number): Mat4 {
  const out = mat4Identity();
  const cos = Math.cos(radians);
  const sin = Math.sin(radians);
  out[0] = cos;
  out[2] = -sin;
  out[8] = sin;
  out[10] = cos;
  return out;
}

/** Transforms a point (implicit w=1) through `matrix`, returning the full `[x, y, z, w]`. */
function mat4TransformPoint(matrix: Mat4, x: number, y: number, z: number): [number, number, number, number] {
  return [
    matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
    matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
    matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14],
    matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15],
  ];
}

// ---------------------------------------------------------------------------
// Sphere geometry + lat/long placement — a standard equirectangular UV
// sphere. `phi`/`theta` conventions are shared between the mesh generator
// and `latitudeLongitudeToPosition` below so markers land in the same
// coordinate space the mesh (and its texture UVs) use.
// ---------------------------------------------------------------------------

interface SphereGeometry {
  positions: Float32Array;
  uvs: Float32Array;
  indices: Uint16Array;
}

function buildSphereGeometry(latitudeSegments: number, longitudeSegments: number): SphereGeometry {
  const positions: number[] = [];
  const uvs: number[] = [];
  const indices: number[] = [];

  for (let latitudeIndex = 0; latitudeIndex <= latitudeSegments; latitudeIndex++) {
    const theta = (latitudeIndex / latitudeSegments) * Math.PI;
    const sinTheta = Math.sin(theta);
    const cosTheta = Math.cos(theta);
    for (let longitudeIndex = 0; longitudeIndex <= longitudeSegments; longitudeIndex++) {
      const phi = (longitudeIndex / longitudeSegments) * Math.PI * 2;
      const x = -Math.cos(phi) * sinTheta;
      const y = cosTheta;
      const z = Math.sin(phi) * sinTheta;
      positions.push(x, y, z);
      uvs.push(longitudeIndex / longitudeSegments, latitudeIndex / latitudeSegments);
    }
  }

  const columnCount = longitudeSegments + 1;
  for (let latitudeIndex = 0; latitudeIndex < latitudeSegments; latitudeIndex++) {
    for (let longitudeIndex = 0; longitudeIndex < longitudeSegments; longitudeIndex++) {
      const a = latitudeIndex * columnCount + longitudeIndex;
      const b = a + columnCount;
      indices.push(a, b, a + 1, a + 1, b, b + 1);
    }
  }

  return {
    positions: new Float32Array(positions),
    uvs: new Float32Array(uvs),
    indices: new Uint16Array(indices),
  };
}

/** Same `theta`/`phi` convention `buildSphereGeometry` uses, so markers align with the mesh/texture. */
function latitudeLongitudeToPosition(latitudeDegrees: number, longitudeDegrees: number): [number, number, number] {
  const theta = ((90 - latitudeDegrees) * Math.PI) / 180;
  const phi = ((longitudeDegrees + 180) * Math.PI) / 180;
  const sinTheta = Math.sin(theta);
  return [-Math.cos(phi) * sinTheta, Math.cos(theta), Math.sin(phi) * sinTheta];
}

// ---------------------------------------------------------------------------
// Shaders (GLSL ES 1.00 / WebGL1). Diffuse lighting + a fresnel-rim
// "atmosphere" term; the bump texture's red channel scales the diffuse term
// rather than perturbing the normal (see the file-header fidelity note).
// ---------------------------------------------------------------------------

const VERTEX_SHADER_SOURCE = `
  attribute vec3 aPosition;
  attribute vec2 aUv;
  uniform mat4 uModel;
  uniform mat4 uView;
  uniform mat4 uProjection;
  varying vec3 vWorldNormal;
  varying vec3 vWorldPosition;
  varying vec2 vUv;
  void main() {
    vec4 worldPosition = uModel * vec4(aPosition, 1.0);
    vWorldPosition = worldPosition.xyz;
    vWorldNormal = mat3(uModel) * aPosition;
    vUv = aUv;
    gl_Position = uProjection * uView * worldPosition;
  }
`;

const FRAGMENT_SHADER_SOURCE = `
  precision mediump float;
  varying vec3 vWorldNormal;
  varying vec3 vWorldPosition;
  varying vec2 vUv;
  uniform sampler2D uBaseTexture;
  uniform sampler2D uBumpTexture;
  uniform vec3 uLightDirection;
  uniform vec3 uCameraPosition;
  uniform vec3 uAtmosphereColor;
  uniform float uAmbientIntensity;
  uniform float uLightIntensity;
  uniform float uAtmosphereIntensity;
  void main() {
    vec3 normal = normalize(vWorldNormal);
    vec3 lightDirection = normalize(uLightDirection);
    float diffuse = max(dot(normal, lightDirection), 0.0);
    float bump = texture2D(uBumpTexture, vUv).r;
    vec3 baseColor = texture2D(uBaseTexture, vUv).rgb;
    vec3 lit = baseColor * (uAmbientIntensity + uLightIntensity * diffuse * (0.65 + 0.35 * bump));
    vec3 viewDirection = normalize(uCameraPosition - vWorldPosition);
    float fresnel = pow(clamp(1.0 - max(dot(normal, viewDirection), 0.0), 0.0, 1.0), 2.5);
    vec3 color = lit + uAtmosphereColor * fresnel * uAtmosphereIntensity;
    gl_FragColor = vec4(color, 1.0);
  }
`;

function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    gl.deleteShader(shader);
    return null;
  }
  return shader;
}

function linkProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | null {
  const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
  const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
  if (!vertexShader || !fragmentShader) return null;
  const program = gl.createProgram();
  if (!program) return null;
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.deleteProgram(program);
    return null;
  }
  return program;
}

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

function hexToUnitFloats(hex: string): [number, number, number] {
  const [r, g, b] = hexToBytes(hex);
  return [r / 255, g / 255, b / 255];
}

/** Deterministic irregular blob outline (not true randomness) around `(centerX, centerY)`, for a
 * procedural "continent" silhouette — a generic, hand-authored technique, not sourced from any
 * specific texture/asset. */
function drawBlob(
  context: CanvasRenderingContext2D,
  centerX: number,
  centerY: number,
  baseRadius: number,
  seed: number,
): void {
  const pointCount = 10;
  context.beginPath();
  for (let index = 0; index <= pointCount; index++) {
    const angle = (index / pointCount) * Math.PI * 2;
    const wobble = 0.65 + 0.35 * Math.sin(angle * 2.7 + seed) + 0.15 * Math.cos(angle * 4.3 + seed * 1.7);
    const radius = baseRadius * wobble;
    const x = centerX + Math.cos(angle) * radius;
    const y = centerY + Math.sin(angle) * radius * 0.7;
    if (index === 0) context.moveTo(x, y);
    else context.lineTo(x, y);
  }
  context.closePath();
  context.fill();
}

/** Builds a procedural Earth-like placeholder texture (ocean + a handful of continent-like blobs)
 * plus a matching grayscale bump canvas, entirely from theme tokens — used whenever the caller
 * doesn't supply `baseTextureUrl`/`bumpTextureUrl`. Returns `null` if canvas 2D isn't available. */
function buildProceduralTextures(
  node: ElementNode,
): { colorCanvas: HTMLCanvasElement; bumpCanvas: HTMLCanvasElement } | null {
  if (typeof document === "undefined") return null;

  const colorCanvas = document.createElement("canvas");
  colorCanvas.width = TEXTURE_SIZE;
  colorCanvas.height = TEXTURE_SIZE / 2;
  const colorContext = colorCanvas.getContext("2d");
  const bumpCanvas = document.createElement("canvas");
  bumpCanvas.width = TEXTURE_SIZE;
  bumpCanvas.height = TEXTURE_SIZE / 2;
  const bumpContext = bumpCanvas.getContext("2d");
  if (!colorContext || !bumpContext) return null;

  const oceanDeep = themeColorToken(node, "shift-13", "info");
  const oceanShallow = themeColorToken(node, "shift-9", "info");
  const landColor = themeColorToken(node, "shift-9", "success");
  const landShade = themeColorToken(node, "shift-11", "success");

  const oceanGradient = colorContext.createLinearGradient(0, 0, 0, colorCanvas.height);
  oceanGradient.addColorStop(0, oceanDeep);
  oceanGradient.addColorStop(0.5, oceanShallow);
  oceanGradient.addColorStop(1, oceanDeep);
  colorContext.fillStyle = oceanGradient;
  colorContext.fillRect(0, 0, colorCanvas.width, colorCanvas.height);

  // The bump canvas is grayscale HEIGHT data (read back as a scalar
  // shading multiplier in the fragment shader), not a themed UI color, so
  // literal grays are intentional here — the same reasoning this package's
  // own `card3D.ts` placeholder-graphic SVG data URI uses for its own
  // literal fill colors (generated raster/vector asset content, not themed
  // chrome). Every user-visible surface color above (`oceanDeep`,
  // `oceanShallow`, `landColor`, `landShade`) IS resolved through
  // `themeColorToken()`.
  bumpContext.fillStyle = "#202020";
  bumpContext.fillRect(0, 0, bumpCanvas.width, bumpCanvas.height);

  // Deterministic blob placements spread across the equirectangular map —
  // fixed coordinates (not `Math.random()`) so the texture is reproducible.
  const blobs: Array<[number, number, number, number]> = [
    [0.12, 0.32, 0.09, 1.1],
    [0.22, 0.62, 0.07, 2.4],
    [0.35, 0.25, 0.06, 0.6],
    [0.48, 0.55, 0.1, 3.3],
    [0.58, 0.28, 0.05, 1.8],
    [0.68, 0.68, 0.08, 4.1],
    [0.78, 0.35, 0.07, 2.9],
    [0.88, 0.58, 0.06, 0.4],
    [0.05, 0.75, 0.05, 3.7],
    [0.92, 0.2, 0.05, 1.2],
  ];

  for (const [fractionX, fractionY, fractionRadius, seed] of blobs) {
    const centerX = fractionX * colorCanvas.width;
    const centerY = fractionY * colorCanvas.height;
    const radius = fractionRadius * colorCanvas.width;

    colorContext.fillStyle = landColor;
    drawBlob(colorContext, centerX, centerY, radius, seed);
    colorContext.fillStyle = landShade;
    drawBlob(colorContext, centerX + radius * 0.15, centerY + radius * 0.15, radius * 0.55, seed + 5);

    bumpContext.fillStyle = "#e8e8e8";
    drawBlob(bumpContext, centerX, centerY, radius, seed);
    bumpContext.fillStyle = "#ffffff";
    drawBlob(bumpContext, centerX - radius * 0.1, centerY - radius * 0.1, radius * 0.4, seed + 5);
  }

  return { colorCanvas, bumpCanvas };
}

function createTextureFromSource(
  gl: WebGLRenderingContext,
  source: TexImageSource,
): WebGLTexture | null {
  const texture = gl.createTexture();
  if (!texture) return null;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.generateMipmap(gl.TEXTURE_2D);
  return texture;
}

function createFlatTexture(gl: WebGLRenderingContext, rgb: [number, number, number]): WebGLTexture | null {
  const texture = gl.createTexture();
  if (!texture) return null;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  const bytes = new Uint8Array([Math.round(rgb[0] * 255), Math.round(rgb[1] * 255), Math.round(rgb[2] * 255), 255]);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  return texture;
}

function loadTextureFromUrl(
  gl: WebGLRenderingContext,
  url: string,
  onLoaded: (texture: WebGLTexture) => void,
): void {
  if (typeof Image === "undefined") return;
  const image = new Image();
  image.crossOrigin = "anonymous";
  image.onload = () => {
    const texture = createTextureFromSource(gl, image);
    if (texture) onLoaded(texture);
  };
  image.src = url;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

interface MarkerRuntime {
  marker: Globe3DMarker;
  element: HTMLElement;
}

/**
 * A realistically textured, slowly auto-rotating 3D Earth (WebGL1, hand-rolled
 * — no `three.js`/scene-graph dependency), with drag-to-orbit, wheel-to-zoom,
 * and avatar-image markers pinned at given coordinates that show a tooltip on
 * hover. Call with no arguments for a working demo — a themed procedural
 * globe auto-rotating with a handful of highlighted city markers.
 */
function globe3D(props: Globe3DProps = {}): DomphyElement<"div"> {
  const markers = props.markers && props.markers.length > 0 ? props.markers : DEFAULT_MARKERS;
  const diameterUnits = props.diameterUnits ?? 100;
  const ambientIntensity = props.ambientIntensity ?? 0.5;
  const lightIntensity = props.lightIntensity ?? 0.9;
  const lightDirection = props.lightDirection ?? [0.6, 0.5, 0.7];
  const atmosphereColorFamily = props.atmosphereColor ?? "info";
  const atmosphereIntensity = props.atmosphereIntensity ?? 0.9;
  const autoRotate = props.autoRotate ?? true;
  const rotationSpeed = props.rotationSpeed ?? 0.0016;
  const minZoomDistance = props.minZoomDistance ?? 1.7;
  const maxZoomDistance = props.maxZoomDistance ?? 4.5;
  const initialZoomDistance = clamp(props.initialZoomDistance ?? 2.6, minZoomDistance, maxZoomDistance);
  const wireframe = props.wireframe ?? false;

  const markerRuntimes: MarkerRuntime[] = [];
  let tooltipElement: HTMLElement | null = null;
  // A real `State` (not a plain closure variable) — the tooltip's own text
  // content reads it via `.get(listener)` so Domphy's reactivity actually
  // re-renders the label on hover change; the per-frame render loop below
  // reads the current value with an untracked `.get()` (it already polls
  // every frame, so it doesn't need a subscription).
  const hoveredMarkerIndexState = toState<number | null>(null);

  const markerElements: DomphyElement<"span">[] = markers.map((marker, markerIndex) => ({
    span: [
      marker.avatarUrl
        ? ({ img: null, src: marker.avatarUrl, alt: marker.label ?? "" } as DomphyElement)
        : (marker.initials ?? "•"),
    ],
    _key: `globe3d-marker-${markerIndex}`,
    $: [avatar({ color: marker.color ?? "primary" })],
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      width: themeSpacing(7),
      height: themeSpacing(7),
      transform: "translate(-9999px, -9999px)",
      opacity: 0,
      pointerEvents: "auto",
      cursor: "pointer",
      transition: "opacity 120ms ease-out",
      willChange: "transform, opacity",
    } as StyleObject,
    _onMount: (markerNode: ElementNode) => {
      const element = markerNode.domElement as HTMLElement;
      markerRuntimes.push({ marker, element });
      const showTooltip = () => {
        hoveredMarkerIndexState.set(markerIndex);
        props.onMarkerHover?.(marker);
      };
      const hideTooltip = () => {
        if (hoveredMarkerIndexState.get() === markerIndex) hoveredMarkerIndexState.set(null);
        props.onMarkerHover?.(null);
      };
      const handleClick = (event: MouseEvent) => props.onMarkerClick?.(marker, event as PointerEvent);
      element.addEventListener("pointerenter", showTooltip);
      element.addEventListener("pointerleave", hideTooltip);
      element.addEventListener("click", handleClick);
      markerNode.addHook("Remove", () => {
        element.removeEventListener("pointerenter", showTooltip);
        element.removeEventListener("pointerleave", hideTooltip);
        element.removeEventListener("click", handleClick);
        const runtimeIndex = markerRuntimes.findIndex((runtime) => runtime.element === element);
        if (runtimeIndex >= 0) markerRuntimes.splice(runtimeIndex, 1);
      });
    },
  })) as DomphyElement<"span">[];

  const tooltip: DomphyElement<"div"> = {
    div: (listener: Listener) => {
      const index = hoveredMarkerIndexState.get(listener);
      return index !== null ? (markers[index]?.label ?? "") : "";
    },
    dataTone: "shift-17",
    dataSize: "decrease-1",
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      transform: "translate(-9999px, -9999px)",
      opacity: 0,
      pointerEvents: "none",
      whiteSpace: "nowrap",
      paddingBlock: themeSpacing(1),
      paddingInline: themeSpacing(3),
      borderRadius: themeSpacing(999),
      transition: "opacity 120ms ease-out",
      zIndex: 10,
      backgroundColor: (listener: Listener) => themeColor(listener),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    } as StyleObject,
    _onMount: (tooltipNode: ElementNode) => {
      tooltipElement = tooltipNode.domElement as HTMLElement;
    },
    _onRemove: () => {
      tooltipElement = null;
    },
  } as DomphyElement<"div">;

  return {
    div: [...markerElements, tooltip],
    role: "img",
    ariaLabel: "Interactive rotating textured globe",
    style: {
      position: "relative",
      width: "100%",
      maxWidth: themeSpacing(diameterUnits),
      aspectRatio: "1 / 1",
      marginInline: "auto",
      contain: "layout paint size",
      ...(props.style ?? {}),
    } as StyleObject,
    _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 = "grab";
      canvas.style.touchAction = "none";
      container.insertBefore(canvas, container.firstChild);

      let gl: WebGLRenderingContext | null = null;
      try {
        gl =
          (canvas.getContext("webgl") as WebGLRenderingContext | null) ??
          (canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
      } catch {
        gl = null;
      }

      let resizeObserver: ResizeObserver | null = null;
      let animationFrameHandle = 0;
      let disposed = false;

      // --- Orbit-control + auto-rotate state (kept regardless of whether
      // WebGL initialized, so the code path is exercised consistently). ---
      let yaw = 0;
      let pitch = -0.25;
      let distance = initialZoomDistance;
      let pointerDown = false;
      let pointerLastX = 0;
      let pointerLastY = 0;

      const handlePointerDown = (event: PointerEvent) => {
        pointerDown = true;
        pointerLastX = event.clientX;
        pointerLastY = event.clientY;
        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 deltaX = event.clientX - pointerLastX;
        const deltaY = event.clientY - pointerLastY;
        pointerLastX = event.clientX;
        pointerLastY = event.clientY;
        yaw += deltaX * 0.006;
        pitch = clamp(pitch + deltaY * 0.006, -1.5, 1.5);
      };
      const handlePointerUp = (event: PointerEvent) => {
        pointerDown = false;
        canvas.style.cursor = "grab";
        try {
          canvas.releasePointerCapture(event.pointerId);
        } catch {
          // Best-effort release, as above.
        }
      };
      const handleWheel = (event: WheelEvent) => {
        event.preventDefault();
        distance = clamp(distance + event.deltaY * 0.0025, minZoomDistance, maxZoomDistance);
      };

      canvas.addEventListener("pointerdown", handlePointerDown);
      window.addEventListener("pointermove", handlePointerMove);
      window.addEventListener("pointerup", handlePointerUp);
      canvas.addEventListener("wheel", handleWheel, { passive: false });

      if (!gl) {
        // No WebGL support (e.g. headless/test runtime) — fail closed to a
        // static (inert but structurally complete) canvas, matching this
        // package's own `magicui/core/globe.ts` fallback idiom.
        node.addHook("Remove", () => {
          disposed = true;
          canvas.removeEventListener("pointerdown", handlePointerDown);
          window.removeEventListener("pointermove", handlePointerMove);
          window.removeEventListener("pointerup", handlePointerUp);
          canvas.removeEventListener("wheel", handleWheel);
        });
        return;
      }

      const geometry = buildSphereGeometry(LATITUDE_SEGMENTS, LONGITUDE_SEGMENTS);
      const program = linkProgram(gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE);

      if (!program) {
        node.addHook("Remove", () => {
          disposed = true;
          canvas.removeEventListener("pointerdown", handlePointerDown);
          window.removeEventListener("pointermove", handlePointerMove);
          window.removeEventListener("pointerup", handlePointerUp);
          canvas.removeEventListener("wheel", handleWheel);
        });
        return;
      }

      const positionBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.STATIC_DRAW);

      const uvBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer);
      gl.bufferData(gl.ARRAY_BUFFER, geometry.uvs, gl.STATIC_DRAW);

      const indexBuffer = gl.createBuffer();
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

      const positionLocation = gl.getAttribLocation(program, "aPosition");
      const uvLocation = gl.getAttribLocation(program, "aUv");
      const modelUniform = gl.getUniformLocation(program, "uModel");
      const viewUniform = gl.getUniformLocation(program, "uView");
      const projectionUniform = gl.getUniformLocation(program, "uProjection");
      const baseTextureUniform = gl.getUniformLocation(program, "uBaseTexture");
      const bumpTextureUniform = gl.getUniformLocation(program, "uBumpTexture");
      const lightDirectionUniform = gl.getUniformLocation(program, "uLightDirection");
      const cameraPositionUniform = gl.getUniformLocation(program, "uCameraPosition");
      const atmosphereColorUniform = gl.getUniformLocation(program, "uAtmosphereColor");
      const ambientIntensityUniform = gl.getUniformLocation(program, "uAmbientIntensity");
      const lightIntensityUniform = gl.getUniformLocation(program, "uLightIntensity");
      const atmosphereIntensityUniform = gl.getUniformLocation(program, "uAtmosphereIntensity");

      // Placeholder 1x1 textures shown immediately; swapped for the real
      // (procedural or loaded) texture once ready, so the sphere is never
      // left un-textured while an image URL is in flight.
      let baseTexture = createFlatTexture(gl, hexToUnitFloats(themeColorToken(node, "shift-9", "info")));
      let bumpTexture = createFlatTexture(gl, [0.5, 0.5, 0.5]);

      const procedural = buildProceduralTextures(node);
      if (procedural) {
        const proceduralBase = createTextureFromSource(gl, procedural.colorCanvas);
        const proceduralBump = createTextureFromSource(gl, procedural.bumpCanvas);
        if (proceduralBase) baseTexture = proceduralBase;
        if (proceduralBump) bumpTexture = proceduralBump;
      }
      if (props.baseTextureUrl) {
        loadTextureFromUrl(gl, props.baseTextureUrl, (texture) => {
          if (!disposed) baseTexture = texture;
        });
      }
      if (props.bumpTextureUrl) {
        loadTextureFromUrl(gl, props.bumpTextureUrl, (texture) => {
          if (!disposed) bumpTexture = texture;
        });
      }

      const atmosphereRgb = hexToUnitFloats(themeColorToken(node, "shift-9", atmosphereColorFamily));

      gl.enable(gl.DEPTH_TEST);
      gl.enable(gl.CULL_FACE);
      gl.cullFace(gl.BACK);

      const projectMarkerToScreen = (
        markerLatitude: number,
        markerLongitude: number,
        viewProjection: Mat4,
        model: Mat4,
        cameraPosition: [number, number, number],
        canvasWidth: number,
        canvasHeight: number,
      ): { x: number; y: number; visible: boolean } | null => {
        const [localX, localY, localZ] = latitudeLongitudeToPosition(markerLatitude, markerLongitude);
        const [worldX, worldY, worldZ] = mat4TransformPoint(model, localX, localY, localZ);
        const worldNormalLength = Math.hypot(worldX, worldY, worldZ) || 1;
        const normalX = worldX / worldNormalLength;
        const normalY = worldY / worldNormalLength;
        const normalZ = worldZ / worldNormalLength;
        const viewX = cameraPosition[0] - worldX;
        const viewY = cameraPosition[1] - worldY;
        const viewZ = cameraPosition[2] - worldZ;
        const viewLength = Math.hypot(viewX, viewY, viewZ) || 1;
        const facing = (normalX * viewX + normalY * viewY + normalZ * viewZ) / viewLength;
        if (facing < 0.05) return { x: 0, y: 0, visible: false };

        const [clipX, clipY, , clipW] = mat4TransformPoint(viewProjection, worldX, worldY, worldZ);
        if (clipW <= 0) return { x: 0, y: 0, visible: false };
        const ndcX = clipX / clipW;
        const ndcY = clipY / clipW;
        return {
          x: (ndcX * 0.5 + 0.5) * canvasWidth,
          y: (1 - (ndcY * 0.5 + 0.5)) * canvasHeight,
          visible: true,
        };
      };

      const render = () => {
        if (disposed || !gl) return;

        const displayWidth = Math.max(1, container.clientWidth);
        const displayHeight = Math.max(1, container.clientHeight);
        const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
        const targetWidth = Math.round(displayWidth * pixelRatio);
        const targetHeight = Math.round(displayHeight * pixelRatio);
        if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
          canvas.width = targetWidth;
          canvas.height = targetHeight;
        }
        gl.viewport(0, 0, canvas.width, canvas.height);

        if (autoRotate && !pointerDown) yaw += rotationSpeed;

        const model = mat4Multiply(mat4RotationY(yaw), mat4RotationX(pitch));
        const view = mat4Translation(0, 0, -distance);
        const projection = mat4Perspective((45 * Math.PI) / 180, canvas.width / canvas.height, 0.1, 100);
        const viewProjection = mat4Multiply(projection, view);
        const cameraPosition: [number, number, number] = [0, 0, distance];

        gl.clearColor(0, 0, 0, 0);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
        gl.useProgram(program);

        gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
        gl.enableVertexAttribArray(positionLocation);
        gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);

        gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer);
        gl.enableVertexAttribArray(uvLocation);
        gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 0, 0);

        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);

        gl.uniformMatrix4fv(modelUniform, false, model);
        gl.uniformMatrix4fv(viewUniform, false, view);
        gl.uniformMatrix4fv(projectionUniform, false, projection);
        gl.uniform3f(lightDirectionUniform, lightDirection[0], lightDirection[1], lightDirection[2]);
        gl.uniform3f(cameraPositionUniform, cameraPosition[0], cameraPosition[1], cameraPosition[2]);
        gl.uniform3f(atmosphereColorUniform, atmosphereRgb[0], atmosphereRgb[1], atmosphereRgb[2]);
        gl.uniform1f(ambientIntensityUniform, ambientIntensity);
        gl.uniform1f(lightIntensityUniform, lightIntensity);
        gl.uniform1f(atmosphereIntensityUniform, atmosphereIntensity);

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, baseTexture);
        gl.uniform1i(baseTextureUniform, 0);
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, bumpTexture);
        gl.uniform1i(bumpTextureUniform, 1);

        gl.drawElements(
          wireframe ? gl.LINES : gl.TRIANGLES,
          geometry.indices.length,
          gl.UNSIGNED_SHORT,
          0,
        );

        // Re-project every marker + the hovered tooltip to 2D screen space
        // this frame, since the globe (and possibly the camera zoom) may
        // have moved since the last one.
        for (const runtime of markerRuntimes) {
          const projected = projectMarkerToScreen(
            runtime.marker.latitude,
            runtime.marker.longitude,
            viewProjection,
            model,
            cameraPosition,
            displayWidth,
            displayHeight,
          );
          if (!projected || !projected.visible) {
            runtime.element.style.opacity = "0";
            continue;
          }
          runtime.element.style.opacity = "1";
          runtime.element.style.transform = `translate(${projected.x}px, ${projected.y}px) translate(-50%, -50%)`;
        }

        if (tooltipElement) {
          const hoveredMarkerIndex = hoveredMarkerIndexState.get();
          if (hoveredMarkerIndex === null) {
            tooltipElement.style.opacity = "0";
          } else {
            const hoveredMarker = markers[hoveredMarkerIndex];
            const projected = hoveredMarker
              ? projectMarkerToScreen(
                  hoveredMarker.latitude,
                  hoveredMarker.longitude,
                  viewProjection,
                  model,
                  cameraPosition,
                  displayWidth,
                  displayHeight,
                )
              : null;
            if (projected?.visible) {
              tooltipElement.style.opacity = "1";
              tooltipElement.style.transform = `translate(${projected.x}px, ${projected.y - 28}px) translate(-50%, -100%)`;
            } else {
              tooltipElement.style.opacity = "0";
            }
          }
        }

        animationFrameHandle = window.requestAnimationFrame(render);
      };

      animationFrameHandle = window.requestAnimationFrame(render);

      if (typeof ResizeObserver !== "undefined") {
        resizeObserver = new ResizeObserver(() => {
          /* canvas backing-store size is recomputed every frame in `render` — this observer
           * only exists so a resize is picked up promptly rather than waiting on the next
           * externally-triggered repaint. */
        });
        resizeObserver.observe(container);
      }

      node.addHook("Remove", () => {
        disposed = true;
        if (animationFrameHandle) window.cancelAnimationFrame(animationFrameHandle);
        resizeObserver?.disconnect();
        canvas.removeEventListener("pointerdown", handlePointerDown);
        window.removeEventListener("pointermove", handlePointerMove);
        window.removeEventListener("pointerup", handlePointerUp);
        canvas.removeEventListener("wheel", handleWheel);
      });
    },
  } as DomphyElement<"div">;
}

export { globe3D };

← Back to Aceternity UI catalog