{"slug":"globe","number":"07","title":"Wireframe globe","era":"Wireframe sphere · vector-display era","section":"historical","provenance":"interpretation","reference":null,"summary":"Latitude/longitude cage with a phosphor stroke. Drag to spin.","description":"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.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["wireframe","3d"],"hasGlsl":false,"interaction":"Drag on the preview to spin the globe. Auto-rotation stops after the first drag.","url":"https://3d-retro.com/experiments/globe","example_url":"https://3d-retro.com/examples/globe.html","api_url":"https://3d-retro.com/api/v1/experiments/globe","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"Latitude/longitude cage with a phosphor stroke. Drag to spin.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/globe\" />\n  <title>Wireframe globe · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    highlight: \"#d2ffec\",\n    speed: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n  function rgbCss(hex, a) {\n    const r = hexToRgb(hex);\n    return \"rgba(\" + r[0] + \", \" + r[1] + \", \" + r[2] + \", \" + a + \")\";\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  // An even band count puts a parallel on the equator (LAT / 2).\n  const LAT = 12;\n  const LON = 24;\n  const verts = [];\n  function sph(lat, lon) {\n    const phi = (lat / LAT) * Math.PI;\n    const th = (lon / LON) * Math.PI * 2;\n    return {\n      x: Math.sin(phi) * Math.cos(th),\n      y: Math.cos(phi),\n      z: Math.sin(phi) * Math.sin(th),\n    };\n  }\n  for (let i = 0; i <= LAT; i++) {\n    for (let j = 0; j <= LON; j++) verts.push(sph(i, j));\n  }\n  function idx(i, j) { return i * (LON + 1) + (j % (LON + 1)); }\n\n  const edges = [];\n  for (let i = 0; i <= LAT; i++) {\n    for (let j = 0; j < LON; j++) {\n      const kind = i === LAT / 2 ? 2 : 0;\n      edges.push(idx(i, j), idx(i, j + 1), kind);\n    }\n  }\n  for (let j = 0; j < LON; j++) {\n    for (let i = 0; i < LAT; i++) {\n      const kind = j === 0 ? 2 : 0;\n      edges.push(idx(i, j), idx(i + 1, j), kind);\n    }\n  }\n\n  const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  let reduced = motionQuery.matches;\n  let yaw = 0.85;\n  let pitch = 0.35;\n  let auto = true;\n  let dragging = false;\n  let lastX = 0, lastY = 0;\n  let last = 0;\n  let raf = 0;\n\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    dragging = true;\n    auto = false;\n    lastX = e.clientX;\n    lastY = e.clientY;\n    canvas.setPointerCapture(e.pointerId);\n  });\n  canvas.addEventListener(\"pointerup\", function () { dragging = false; });\n  canvas.addEventListener(\"pointercancel\", function () { dragging = false; });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    if (!dragging) return;\n    yaw += (e.clientX - lastX) * 0.008;\n    pitch += (e.clientY - lastY) * 0.008;\n    pitch = Math.max(-1.1, Math.min(1.1, pitch));\n    lastX = e.clientX;\n    lastY = e.clientY;\n    wake();\n  });\n\n  // Yaw about the axis, then pitch about the screen's x axis.\n  function rotate(p) {\n    const cy = Math.cos(yaw), sy = Math.sin(yaw);\n    const x1 = p.x * cy + p.z * sy;\n    const z1 = -p.x * sy + p.z * cy;\n    const cp = Math.cos(pitch), sp = Math.sin(pitch);\n    const y2 = p.y * cp - z1 * sp;\n    const z2 = p.y * sp + z1 * cp;\n    return { x: x1, y: y2, z: z2 };\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (auto && !reduced) yaw += dt * 0.28 * PARAMS.speed;\n    resize();\n\n    const w = canvas.width;\n    const h = canvas.height;\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n\n    const cx = w * 0.5;\n    const cy = h * 0.5;\n    // The eye sits CAM radii in front of the sphere, looking along +z, so a\n    // vertex at rotated z = -1 is nearest. Pulling the eye back and scaling\n    // the focal length to match keeps the globe the same size on screen with\n    // gentler foreshortening.\n    const cam = 4.2;\n    const fov = Math.min(w, h) * 1.4;\n    const proj = verts.map(rotate);\n\n    ctx.lineCap = \"round\";\n    ctx.lineJoin = \"round\";\n    for (let e = 0; e < edges.length; e += 3) {\n      const a = proj[edges[e]];\n      const b = proj[edges[e + 1]];\n      const kind = edges[e + 2];\n      const za = a.z + cam;\n      const zb = b.z + cam;\n      if (za < 0.15 || zb < 0.15) continue;\n      // 1 on the near side, 0 on the far side.\n      const depth = 0.5 - 0.25 * (a.z + b.z);\n      const alpha = 0.22 + depth * (kind ? 0.78 : 0.58);\n      const x0 = cx + a.x * fov / za;\n      const y0 = cy - a.y * fov / za;\n      const x1 = cx + b.x * fov / zb;\n      const y1 = cy - b.y * fov / zb;\n      ctx.strokeStyle = rgbCss(PARAMS.color, alpha * 0.28);\n      ctx.lineWidth = kind ? 3.4 : 2.4;\n      ctx.beginPath();\n      ctx.moveTo(x0, y0);\n      ctx.lineTo(x1, y1);\n      ctx.stroke();\n      ctx.strokeStyle = rgbCss(kind ? PARAMS.highlight : PARAMS.color, alpha);\n      ctx.lineWidth = kind ? 1.5 : 1.05;\n      ctx.beginPath();\n      ctx.moveTo(x0, y0);\n      ctx.lineTo(x1, y1);\n      ctx.stroke();\n    }\n\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  // Motion. The system's reduced-motion preference decides by default. A\n  // standalone copy animates anyway with \"#motion=play\" in its URL (or holds a\n  // frame with \"#motion=still\"); an embedding page can post {motion: \"play\"}.\n  let motionMode = \"auto\";\n  function applyMotion() {\n    const next = motionMode === \"still\" || (motionMode === \"auto\" && motionQuery.matches);\n    if (next === reduced) return;\n    reduced = next;\n    last = 0;\n    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }\n    else if (!raf) raf = requestAnimationFrame(frame);\n  }\n  function setMotion(mode) {\n    if (mode === \"play\" || mode === \"still\" || mode === \"auto\") { motionMode = mode; applyMotion(); }\n  }\n  function readMotionHash() {\n    const m = /motion=(play|still|auto)/.exec(location.hash);\n    let mode = m ? m[1] : \"auto\";\n    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.\n    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === \"string\") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }\n    setMotion(mode);\n  }\n  function wake() { if (reduced) frame(performance.now()); }\n  if (motionQuery.addEventListener) motionQuery.addEventListener(\"change\", applyMotion);\n  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);\n  window.addEventListener(\"hashchange\", readMotionHash);\n  window.addEventListener(\"message\", function (e) { if (e.data && typeof e.data.motion === \"string\") setMotion(e.data.motion); });\n  window.addEventListener(\"resize\", wake);\n\n  window.__globeTest = {\n    LAT: LAT, LON: LON, verts: verts, edges: edges,\n    rotate: rotate,\n    getView: function () { return { yaw: yaw, pitch: pitch }; },\n    setView: function (y, p) { yaw = y; pitch = p; auto = false; wake(); },\n    project: function (p, w, h) {\n      const r = rotate(p), cam = 4.2, fov = Math.min(w, h) * 1.4;\n      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 };\n    }\n  };\n\n  if (reduced) frame(performance.now());\n  else raf = requestAnimationFrame(frame);\n  readMotionHash();\n})();\n</script>\n</body>\n</html>\n"}