Home

07. Wireframe sphere · vector-display era · Retro techniques

Wireframe globe

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

A UV sphere as meridians and parallels, projected with a 1/z divide. Near edges are brighter than the far side, so the globe reads in depth without hidden-line removal. The equator and prime meridian are a touch hotter. It is the generic spinning wire globe of vector displays and early workstation demos, not a specific program. Drag to orbit. It keeps a slow auto-spin until you take over.

Drag on the preview to spin the globe. Auto-rotation stops after the first drag.

Reference

Interpretation: this evokes a period look and does not reproduce one specific original.

Browser APIs

  • Canvas 2D
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    highlight: "#d2ffec",
    speed: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function rgbCss(hex, a) {
    const r = hexToRgb(hex);
    return "rgba(" + r[0] + ", " + r[1] + ", " + r[2] + ", " + a + ")";
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  // An even band count puts a parallel on the equator (LAT / 2).
  const LAT = 12;
  const LON = 24;
  const verts = [];
  function sph(lat, lon) {
    const phi = (lat / LAT) * Math.PI;
    const th = (lon / LON) * Math.PI * 2;
    return {
      x: Math.sin(phi) * Math.cos(th),
      y: Math.cos(phi),
      z: Math.sin(phi) * Math.sin(th),
    };
  }
  for (let i = 0; i <= LAT; i++) {
    for (let j = 0; j <= LON; j++) verts.push(sph(i, j));
  }
  function idx(i, j) { return i * (LON + 1) + (j % (LON + 1)); }

  const edges = [];
  for (let i = 0; i <= LAT; i++) {
    for (let j = 0; j < LON; j++) {
      const kind = i === LAT / 2 ? 2 : 0;
      edges.push(idx(i, j), idx(i, j + 1), kind);
    }
  }
  for (let j = 0; j < LON; j++) {
    for (let i = 0; i < LAT; i++) {
      const kind = j === 0 ? 2 : 0;
      edges.push(idx(i, j), idx(i + 1, j), kind);
    }
  }

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  let yaw = 0.85;
  let pitch = 0.35;
  let auto = true;
  let dragging = false;
  let lastX = 0, lastY = 0;
  let last = 0;
  let raf = 0;

  canvas.addEventListener("pointerdown", function (e) {
    dragging = true;
    auto = false;
    lastX = e.clientX;
    lastY = e.clientY;
    canvas.setPointerCapture(e.pointerId);
  });
  canvas.addEventListener("pointerup", function () { dragging = false; });
  canvas.addEventListener("pointercancel", function () { dragging = false; });
  canvas.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    yaw += (e.clientX - lastX) * 0.008;
    pitch += (e.clientY - lastY) * 0.008;
    pitch = Math.max(-1.1, Math.min(1.1, pitch));
    lastX = e.clientX;
    lastY = e.clientY;
    wake();
  });

  // Yaw about the axis, then pitch about the screen's x axis.
  function rotate(p) {
    const cy = Math.cos(yaw), sy = Math.sin(yaw);
    const x1 = p.x * cy + p.z * sy;
    const z1 = -p.x * sy + p.z * cy;
    const cp = Math.cos(pitch), sp = Math.sin(pitch);
    const y2 = p.y * cp - z1 * sp;
    const z2 = p.y * sp + z1 * cp;
    return { x: x1, y: y2, z: z2 };
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function frame(now) {
    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (auto && !reduced) yaw += dt * 0.28 * PARAMS.speed;
    resize();

    const w = canvas.width;
    const h = canvas.height;
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);

    const cx = w * 0.5;
    const cy = h * 0.5;
    // The eye sits CAM radii in front of the sphere, looking along +z, so a
    // vertex at rotated z = -1 is nearest. Pulling the eye back and scaling
    // the focal length to match keeps the globe the same size on screen with
    // gentler foreshortening.
    const cam = 4.2;
    const fov = Math.min(w, h) * 1.4;
    const proj = verts.map(rotate);

    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    for (let e = 0; e < edges.length; e += 3) {
      const a = proj[edges[e]];
      const b = proj[edges[e + 1]];
      const kind = edges[e + 2];
      const za = a.z + cam;
      const zb = b.z + cam;
      if (za < 0.15 || zb < 0.15) continue;
      // 1 on the near side, 0 on the far side.
      const depth = 0.5 - 0.25 * (a.z + b.z);
      const alpha = 0.22 + depth * (kind ? 0.78 : 0.58);
      const x0 = cx + a.x * fov / za;
      const y0 = cy - a.y * fov / za;
      const x1 = cx + b.x * fov / zb;
      const y1 = cy - b.y * fov / zb;
      ctx.strokeStyle = rgbCss(PARAMS.color, alpha * 0.28);
      ctx.lineWidth = kind ? 3.4 : 2.4;
      ctx.beginPath();
      ctx.moveTo(x0, y0);
      ctx.lineTo(x1, y1);
      ctx.stroke();
      ctx.strokeStyle = rgbCss(kind ? PARAMS.highlight : PARAMS.color, alpha);
      ctx.lineWidth = kind ? 1.5 : 1.05;
      ctx.beginPath();
      ctx.moveTo(x0, y0);
      ctx.lineTo(x1, y1);
      ctx.stroke();
    }

    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  window.__globeTest = {
    LAT: LAT, LON: LON, verts: verts, edges: edges,
    rotate: rotate,
    getView: function () { return { yaw: yaw, pitch: pitch }; },
    setView: function (y, p) { yaw = y; pitch = p; auto = false; wake(); },
    project: function (p, w, h) {
      const r = rotate(p), cam = 4.2, fov = Math.min(w, h) * 1.4;
      return { x: w * 0.5 + r.x * fov / (r.z + cam), y: h * 0.5 - r.y * fov / (r.z + cam), z: r.z + cam };
    }
  };

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();