{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-globe",
  "type": "registry:block",
  "title": "Data Globe",
  "description": "A dotted-land globe with comet arcs flying between cities; each lands with a ripple and new routes keep spawning. Hover a city for its label, drag to spin with momentum; light or dark stage.",
  "docs": "Data Globe was added to public/vividsites/data-globe.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/data-globe",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/data-globe.html",
      "type": "registry:file",
      "target": "public/vividsites/data-globe.html",
      "content": "<!--\n  vividsites · data-globe\n  A dotted-land globe with live arcs flying between cities. Each arc draws in as a\n  comet, lands with a ripple, then retracts; new routes keep spawning. Hover (or tap)\n  a city for its label. Drag to spin with momentum; arrow keys when focused.\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-stage   light | dark                               (default light)\n    data-arcs    arcs in flight at once, 1 – 24             (default 10)\n  CSS variables:\n    --vs-data-globe-bg      the stage                        (light #ecebe6, dark #0c0e11)\n    --vs-data-globe-ink     the land dots                    (light #1d1d1b, dark #d3dae2)\n    --vs-data-globe-fill    the globe's body                 (light #f5f4f0, dark #12161b)\n    --vs-data-globe-accent  arcs, cities, ripples            (#ff4a1c)\n    --vs-data-globe-speed   idle spin multiplier, 0 = still  (1)\n    --vs-data-globe-height  component height                 (100svh)\n  JS:\n    el.vsDataGlobe.set({ stage: 'dark', arcs: 16, accent: '#1f6bff', speed: 0.5 })\n    el.vsDataGlobe.set({ cities: [{ name: 'Lisbon', lat: 38.72, lon: -9.14 }, ...] })\n    el.vsDataGlobe.project(38.72, -9.14)   // → { x, y, visible } in element px, for your own labels\n    el.vsDataGlobe.destroy()\n  Needs: three@0.170.0 (importmap below). Remove the importmap if your page already maps \"three\".\n-->\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500&family=JetBrains+Mono:wght@400&display=swap\">\n<div class=\"vs-data-globe\" data-stage=\"light\" data-arcs=\"10\" tabindex=\"0\" role=\"img\" aria-label=\"A globe with live routes arcing between cities. Drag to spin.\">\n  <canvas class=\"vs-data-globe-canvas\"></canvas>\n  <div class=\"vs-data-globe-tip\" aria-hidden=\"true\"><i></i><b></b><span></span></div>\n  <div class=\"vs-data-globe-fallback\" aria-hidden=\"true\"><i></i></div>\n</div>\n\n<style>\n  .vs-data-globe {\n    --vs-data-globe-bg: #ecebe6;\n    --vs-data-globe-ink: #1d1d1b;\n    --vs-data-globe-fill: #f5f4f0;\n    --vs-data-globe-accent: #ff4a1c;\n    --vs-data-globe-speed: 1;\n    --vs-data-globe-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-data-globe-height); min-height: 380px; overflow: hidden; isolation: isolate;\n    background: var(--vs-data-globe-bg); cursor: grab; outline: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n    transition: background-color .7s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-data-globe[data-stage=\"dark\"] { --vs-data-globe-bg: #0c0e11; --vs-data-globe-ink: #d3dae2; --vs-data-globe-fill: #12161b; }\n  .vs-data-globe.is-dragging { cursor: grabbing; }\n  .vs-data-globe.is-hot { cursor: pointer; }\n  .vs-data-globe:focus-visible { box-shadow: inset 0 0 0 2px var(--vs-data-globe-accent); }\n  .vs-data-globe-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transform: scale(.94);\n    transition: opacity 1.1s cubic-bezier(.16,1,.3,1), transform 1.6s cubic-bezier(.16,1,.3,1); }\n  .vs-data-globe[data-vs-state=\"ready\"] .vs-data-globe-canvas { opacity: 1; transform: none; }\n  /* the hover label: a hairline glass chip that rides the city on a spring */\n  .vs-data-globe-tip { position: absolute; left: 0; top: 0; z-index: 2; pointer-events: none; display: grid; grid-template-columns: auto 1fr; column-gap: 8px; row-gap: 3px; align-items: center;\n    padding: 8px 11px 8px 10px; border-radius: 10px; white-space: nowrap; will-change: transform, opacity; opacity: 0;\n    font: 500 12px/1.1 \"Space Grotesk\", ui-sans-serif, system-ui, sans-serif; color: var(--vs-data-globe-ink);\n    background: color-mix(in srgb, var(--vs-data-globe-fill) 78%, transparent);\n    -webkit-backdrop-filter: blur(12px) saturate(1.3); backdrop-filter: blur(12px) saturate(1.3);\n    box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 0 0 .5px color-mix(in srgb, var(--vs-data-globe-ink) 16%, transparent), 0 6px 18px -6px rgba(0,0,0,.25), 0 18px 40px -18px rgba(0,0,0,.3); }\n  .vs-data-globe[data-stage=\"dark\"] .vs-data-globe-tip { box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 0 .5px rgba(255,255,255,.12), 0 8px 24px -6px rgba(0,0,0,.6); }\n  .vs-data-globe-tip i { grid-row: span 2; width: 7px; height: 7px; border-radius: 50%; background: var(--vs-data-globe-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--vs-data-globe-accent) 25%, transparent); }\n  .vs-data-globe-tip b { font-weight: 500; letter-spacing: -.005em; }\n  .vs-data-globe-tip span { font: 400 10px/1 \"JetBrains Mono\", ui-monospace, Menlo, monospace; letter-spacing: .04em; opacity: .6; font-variant-numeric: tabular-nums; }\n  /* no WebGL: a printed dot globe */\n  .vs-data-globe-fallback { position: absolute; inset: 0; display: none; place-items: center; }\n  .vs-data-globe-fallback i { width: min(72vmin, 620px); aspect-ratio: 1; border-radius: 50%; background:\n    radial-gradient(circle at 36% 30%, rgba(255,255,255,.5), transparent 55%),\n    radial-gradient(circle, var(--vs-data-globe-ink) 1.1px, transparent 1.6px) 0 0 / 9px 9px, var(--vs-data-globe-fill);\n    opacity: .9; box-shadow: 0 40px 60px -40px rgba(0,0,0,.35); }\n  .vs-data-globe[data-vs-state=\"nogl\"] .vs-data-globe-fallback { display: grid; }\n  @media (prefers-reduced-motion: reduce) { .vs-data-globe-canvas { transition: opacity .3s; transform: none; } }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\n// 360×180 equirectangular land mask, 1 bit per degree (Natural Earth 110m, public domain)\nconst LAND = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADg/wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADA/v8/AOD//z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+///D/////5//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAH/j/+//////8PAAAAgB0AAHgAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAOD//wP8//////8BAACAfx4AAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAAAAA4+I73//n///////8AAAAAfwAAAAAAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAOAQACDwP4D///////8BAAAAPAQAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAADzAgPP5H8D//////38AAAAAAAAAAAAA4AEAAAD+DwAAAAAAAAAAAAAAAAAAAIC/g7PfBwAA/v////8AAAAAAAAAAADgBwAAwP//PwAA4B8AAAAAAAAAAAAAAAAMAAD8AgAA+P////8AAAAAAAAAAABwAAAA/P//AwAAAAAAAAAAAAAAAAAAAP8Ahvc5OwAA8P///38AAAAAAAAAAAAcAADg////34cHAAcAAAAAAAAAAAAAgN9/xzf8RwAA4P///z8AAAAAAAAAAAAPAB7g//////8fgB8AAAAAAT4AAAAAAO//B3f8/wsA4P///z8AAAAAAAAAAAAPAG////////8f2f8HAAAAAAD4HwAAAAD8H/D4/38A4P///xMAAAAAAPAHAAAAgN//////////////DwAAAAD+///h/4//H+/Bg/8BwP7//w8AAAAAwP8fAAAAj9///////////////88//h8AAAAAAAD8+Dsw+AH//wAAAP//////DwAA/P8cgEEAAAAAAAAAAAAAAAAA8P8AAAAAAAAAAAAQeAbwPwAA+P//////AwAA4AwAAGAAAAAAAAAAAAAAAAAAIH4DAAAAAAAAAAAA/gPAfwAA/v//////AQBg4AAAABAAAAAAAAAAAAAAAAAAAQ8AAAAAAAAAAACg/wPnfwDw/x/A////AByADwAAAAAAAAAAAAAAAAAAAAAAQID4///////////Pw/0DAP8HAMAfAACA//F//v////////////////////8/AAP4///////////BBPAHAP4HAAAAAADg//z///////////////////////9/AID///////////8AQ8ABAPwBAAAAAAD8P/7///////////////////////9fAMD//////////z8AwA8AAPgBAAAAAAD+H/7/////////////////////Of8BAID///z//////z8AwD8AAMABAAAAAAD+P/z///////////////////9/wH8AAAD8MwD//////x8AwH8MAAAAAAAAAAD+P2D///////////////////95cAAAAACAAwD8/////38AwP8eAAAAAAAAgAGcH/D//////////////////wEAOAAAAADADgDA/////38AgP8/AAAAAAAAwANAH/T//////////////////wAAfgAAAAAwAAAA//////8PgP8/AAAAAAAAwAFwD/7/////////////////PwAAfwAAAAAGAAAA//////8/wP//AAAAAAAAwAOwA/7/////////////////DwCAPwAAAIAAAAAA/v//////8///BwAAAAAAOAcgcP//////////////////HwAAHwAAAAAAAACA+P//////4///DwAAAAAAOA74/////////////////////wUADwAAAAAAAAAA8P//////4///DwAAAAAAGD///////////////////////wUAAwAAAAAAAAAA8P//////7///FwAAAAAAAB///////////////////////wUAAgAAAAAAAAAA0P//////////CAAAAAAAgOH//////////////////////w0AAAAAAAAAAAAAYP////////8xLAAAAAAAAPz//////////////////////wwAAAAAAAAAAAAAAP7//////38HfgAAAAAAgP///////////////////////wQAAAAAAAAAAAAAAP3///////8HUAAAAAAAAP7/////////////////////fwQAAAAAAAAAAAAAAP////////+XAAAAAAAAAPz///93/D/+////////////PwwAAAAAAAAAAAAAAP////////9/AAAAAAAAAPj//f9j/g/+////////////HwAAAAAAAAAAAAAAAP////////8MAAAAAAAAAPj/+P8B/Mf/////////////DwQAAAAAAAAAAAAAAP///////z8AAAAAAAAAcPzH8/8A8I//////////////Bx4AAAAAAAAAAAAAAP///////x8AAAAAAAAA+H+Aw/8AwA/+//////////9/AA8AAAAAAAAAAAAAAP///////x8AAAAAAAAA+D8Aj//w4R/8//////////8/AAAAAAAAAAAAAAAAAP///////wMAAAAAAAAA+B8wuE/+/z/+/////////98fAAMAAAAAAAAAAAAAAP///////wMAAAAAAAAA+A8wEMf//x/+/////////0cOAAMAAAAAAAAAAAAAAP7//////wEAAAAAAAAA+A8AAI7//x/8/////////wMOAAEAAAAAAAAAAAAAAP7//////wAAAAAAAAAA+AcABob//z/8/////////zccwAEAAAAAAAAAAAAAAPz//////wAAAAAAAAAAQOB/AAQ2/////////////x8c8AEAAAAAAAAAAAAAAPj//////wAAAAAAAAAAQPx/AAAA/////////////w8Y/gEAAAAAAAAAAAAAAPD/////fwAAAAAAAAAA4P8/AAAA/////////////w+EGwAAAAAAAAAAAAAAAMD/////HwAAAAAAAAAA8P9/AACA/////////////x/AAwAAAAAAAAAAAAAAAID/////DwAAAAAAAAAA+P//BwaA/////////////x/AAAAAAAAAAAAAAAAAAID9////BwAAAAAAAAAA/P//D3+E/////////////z9AAAAAAAAAAAAAAAAAAAD5////BwAAAAAAAAAA/P//f////////////////x8AAAAAAAAAAAAAAAAAAADy/x8GBgAAAAAAAAAA/P/////v/8///////////z8AAAAAAAAAAAAAAAAAAAD0/w8ABgAAAAAAAAAA/v////+//4///////////z8AAAAAAAAAAAAAAAAAAADu/wcADgAAAAAAAACA//////8f/x/+/////////x8AAAAAAAAAAAAAAAAAAACI/wcALAAAAAAAAADA//////8//z/g/////////w8AAAAAAAAAAAAAAAAAAACQ/wcACAAAAAAAAADg//////9//r+A/////////wcAAAAAAAAAAAAAAAAAAAAQ/wMAAAAAAAAAAADg//////9//n8cgP///////ycAAAAAAAAAAAAAAAAAAAAA/gMAAAAAAAAAAADw////////+P9/AP///////xEAAAAAAAAAAAAAAAAAAAAA/AMAHQAAAAAAAADw////////+P//APz/f///fxAAAAAAAAAAAAAAAAAAAAAA+AcAYAAAAAAAAAD4////////+f9/APz/B///BgAAAAAAAAAAAAAAAAAAAAAA/AccgAEAAAAAAADw////////8f9/AOD/B/5/AAAAAAAAAAAAAAAAAQAAAAAA+A8eADgAAAAAAADw////////4f8/AOD/Afw/BgAAAAAAAAAAAAAAAAAAAAAA8J8PAPwCAAAAAADw////////4/8fAOD/APw/AgAAAAAAAAAAAAAAAAAAAAAAgP8PAAAAAAAAAADw////////x/8HAOB/APx/ADAAAAAAAAAAAAAAAAAAAAAAAP4PAAAAAAAAAAD4////////h/8BAOA/APz/ADAAAAAAAAAAAAAAAAAAAAAAAID/AAAAAAAAAAD4////////j/8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAAD/AQAAAAAAAAD4////////nx8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAD4////////vwcAAIAPAMD/AcAAAAAAAAAAAAAAAAAAAAAAAADgAQAAAAAAAAD4////////fwAAAIAPAMD8AQABAAAAAAAAAAAAAAAAAAAAAADAAAgAAAAAAADw////////f2AAAAAPAID4AUACAAAAAAAAAAAAAAAAAAAAAADAAe9zAAAAAADg/////////34AAAAPAEBwAAgAAAAAAAAAAAAAAAAAAAAAAAAAE+9/AAAAAADA/////////38AAAAXAEAgAAQCAAAAAAAAAAAAAAAAAAAAAAAAz///AAAAAACA/////////z8AAAASAMAAAIACAAAAAAAAAAAAAAAAAAAAAAAAyP//AQAAAACA/////////z8AAAAwAIAAAEAHAAAAAAAAAAAAAAAAAAAAAAAAgP//AwAAAAAA/v///////x8AAAAwAAADAAMBAAAAAAAAAAAAAAAAAAAAAAAAwP//fwAAAAAA/A/+/////x8AAAAAAAAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAEAD8/////w8AAAAAADAGwAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wEAAAAAAADA/////wcAAAAAAGAG4AEAAAAAAAAAAAAAAAAAAAAAAAAA4P///wEAAAAAAADA/////wMAAAAAAMAM+AMAAAAAAAAAAAAAAAAAAAAAAAAA4P///wMAAAAAAADg/////wEAAAAAAIAL/gMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wMAAAAAAADg////fwAAAAAAAIAH/vMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wcAAAAAAADg////PwAAAAAAAAAP/gMAAQAAAAAAAAAAAAAAAAAAAAAA+P///38AAAAAAADg////PwAAAAAAAAAO/DmAAwAAAAAAAAAAAAAAAAAAAAAA+P///38BAAAAAADA////HwAAAAAAAAA+/DkA8gAAAAAAAAAAAAAAAAAAAAAA8P////8fAAAAAACA////DwAAAAAAAAA8wChE/gcAAAAAAAAAAAAAAAAAAAAA+P////8/AAAAAACA////BwAAAAAAAAA4AEAA8B8AAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAAAwAEAAxD8NAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAADAAQAAxP+AAAAAAAAAAAAAAAAAAAAA8P//////AQAAAAAA/v//BwAAAAAAAACAHwAAwH8ABAAAAAAAAAAAAAAAAAAA4P//////AQAAAAAA/v//BwAAAAAAAAAAQFYEAMcAAAAAAAAAAAAAAAAAAAAA4P//////AAAAAAAA/v//DwAAAAAAAAAAAAgBAIABMAAAAAAAAAAAAAAAAAAAwP//////AAAAAAAA/v//DwAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAgP////9/AAAAAAAA/P//DwAAAAAAAAAAAAAAAQQAAAAAAAAAAAAAAAAAAAAAgP////8/AAAAAAAA/v//HyAAAAAAAAAAAACAHwQAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA/v//HyAAAAAAAAAAAADADwwAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA////HzAAAAAAAAAAAADsDxwAAAAAAAAAAAAAAAAAAAAAAP7///8fAAAAAAAA////DzgAAAAAAAAAAAD/DxwAAAAA/////////////////wcAAADg////////AAAA8MD///////////8AwMH///d/AAAAAAAAAAAAAAAAAOD///8fAAAAAAAA////Ax8AAAAAAAAAAMD//z4AAABAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA////AB8AAAAAAAAAAMD//z8AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAOD//38AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAPz///8BAAEAAAAAAAAAAAAAAAAAAMD///8HAAAAAAAA/P9/gA8AAAAAAAAAgP////8BAAIAAAAAAAAAAAAAAAAAAMD///8DAAAAAAAA/P//gA8AAAAAAAAAwP////8DAAAAAAAAAAAAAAAAAAAAAMD//38AAAAAAAAA/P9/AA8AAAAAAAAAwP////8HAAAAAAAAAAAAAAAAAAAAAOD//x8AAAAAAAAA+P9/AAcAAAAAAAAA4P////8PAAAAAAAAAAAAAAAAAAAAAOD//w8AAAAAAAAA+P8fAAIAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAA4P////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA8P8PAAAAAAAAAAAAgP////8/AAAAAAAAAAAAAAAAAAAAAOD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wEAAAAAAAAAwP8DAAAAAAAAAAAAAP////8fAAAAAAAAAAAAAAAAAAAAAOD//wAAAAAAAAAAwP8BAAAAAAAAAAAAAP8D/P8PAAAAAAAAAAAAAAAAAAAAAPD/fwAAAAAAAAAAwH8AAAAAAAAAAAAAgP8A2P8HAAAAAAAAAAAAAAAAAAAAAPD/OwAAAAAAAAAAgAEAAAAAAAAAAAAAAAcA6P8HAAAAAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP8DAAACAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAEAAAAAAAAAAAAAAAAAPz/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAIAAAAAAAAAAAAAAAAAPj/AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGgAAAA4AAAAAAAAAAAAAAAAAPg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAPw/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAALAAAAAAAAAAAAAAAAAPwfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAIADAAAAAAAAAAAAAAAAAPgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAMABAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAP4DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH6AAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAIAfAAAAAAAeeADADwAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAPD/AADA/////////z8AAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAgP///wH4//////////8DAAAAAAAAAAAAAAAAAAAAADAfAAAAAAAAAAAAAADw8////wP+////////////AwAAAAAAAAAAAAAAAAAAAHA/AAAAAAAAAAA8//P//////+D/////////////HwAAAAAAAAAAAAAAAAAAAP5+AAAAAAAA5v////////////D//////////////38AAAAAAAAAAAAAAAAAAAB+AAAAAACA/////////////////////////////38AAAAAAAAAYAYA4ON/OPh/AAAAAADw/////////////////////////////x8AAAAAAADwv/MXgP////8fAAAAAADw/////////////////////////////wMAAAAAAPz///////////8HAAAAAAD+/////////////////////////////wAAAAAAAPz//////////z8AAAAAAP///////////////////////////////wAAAADA/////////////wEAAAAA8P///////////////////////////////wAAAACH////////////PwAAAPgA/////////////////////////////////wcAAAAcgP//////////fwAAAP4BwP//////////////////////////////HwAAAAAAAP7//////////wf8wD8AwP//////////////////////////////DwAAAAAA/v////////////8HAAD4////////////////////////////////HwAAAAAA+P//////////////4f///////////////////////////////////wAAAAAA/P///////////////////////////////////////////////////x8A3x8AAP7/////////////////////////////////////////////fwAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';\nconst CITIES = [\n  { name: 'New York', lat: 40.71, lon: -74.01 }, { name: 'San Francisco', lat: 37.77, lon: -122.42 }, { name: 'Mexico City', lat: 19.43, lon: -99.13 },\n  { name: 'São Paulo', lat: -23.55, lon: -46.63 }, { name: 'Bogotá', lat: 4.71, lon: -74.07 }, { name: 'Reykjavík', lat: 64.15, lon: -21.94 },\n  { name: 'London', lat: 51.51, lon: -0.13 }, { name: 'Lisbon', lat: 38.72, lon: -9.14 }, { name: 'Berlin', lat: 52.52, lon: 13.4 },\n  { name: 'Lagos', lat: 6.52, lon: 3.38 }, { name: 'Nairobi', lat: -1.29, lon: 36.82 }, { name: 'Cape Town', lat: -33.92, lon: 18.42 },\n  { name: 'Cairo', lat: 30.04, lon: 31.24 }, { name: 'Istanbul', lat: 41.01, lon: 28.98 }, { name: 'Dubai', lat: 25.2, lon: 55.27 },\n  { name: 'Mumbai', lat: 19.08, lon: 72.88 }, { name: 'Singapore', lat: 1.35, lon: 103.82 }, { name: 'Hong Kong', lat: 22.32, lon: 114.17 },\n  { name: 'Seoul', lat: 37.57, lon: 126.98 }, { name: 'Tokyo', lat: 35.68, lon: 139.69 }, { name: 'Sydney', lat: -33.87, lon: 151.21 },\n  { name: 'Auckland', lat: -36.85, lon: 174.76 }, { name: 'Anchorage', lat: 61.22, lon: -149.9 }, { name: 'Lima', lat: -12.05, lon: -77.04 },\n];\n\nclass Spring { // damped spring, substepped so it's frame-rate independent\n  constructor(x, k = 120, c = 18) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)); const h = dt / n; for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; } return this.x; }\n}\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nconst toVec = (lat, lon, r = 1) => { const p = THREE.MathUtils.degToRad(90 - lat), t = THREE.MathUtils.degToRad(lon + 180); return new THREE.Vector3(-r * Math.sin(p) * Math.cos(t), r * Math.cos(p), r * Math.sin(p) * Math.sin(t)); };\n\nconst DOT_VERT = /* glsl */`\n  uniform float uSize, uScale; attribute float aSeed; varying float vA, vSeed;\n  void main(){\n    vec4 mv = modelViewMatrix * vec4(position, 1.0);\n    vec3 n = normalize(normalMatrix * position);\n    vec3 toCam = normalize(-mv.xyz);\n    float f = dot(n, toCam);\n    vA = smoothstep(-0.02, 0.35, f);\n    vSeed = aSeed;\n    gl_PointSize = uSize * uScale * mix(0.55, 1.0, smoothstep(0.0, 0.6, f));\n    gl_Position = projectionMatrix * mv;\n  }`;\nconst DOT_FRAG = /* glsl */`\n  uniform vec3 uInk; uniform float uAlpha; varying float vA, vSeed;\n  void main(){\n    float d = length(gl_PointCoord - 0.5);\n    float a = smoothstep(0.5, 0.36, d) * vA * uAlpha * mix(0.7, 1.0, vSeed);\n    if (a < 0.01) discard;\n    gl_FragColor = vec4(uInk, a);\n    #include <colorspace_fragment>\n  }`;\nconst BODY_FRAG = /* glsl */`\n  uniform vec3 uFill, uAccent; uniform float uDark; varying vec3 vN, vV;\n  float pw(float x, float e){ return pow(max(x, 0.0), e); }\n  void main(){\n    vec3 n = normalize(vN), v = normalize(vV);\n    float key = dot(n, normalize(vec3(-0.5, 0.7, 0.6))) * 0.5 + 0.5;\n    float rim = pw(1.0 - max(dot(n, v), 0.0), 5.0);\n    vec3 c = uFill * mix(mix(0.84, 0.72, uDark), mix(1.03, 1.35, uDark), key);\n    c = mix(c, uAccent, rim * mix(0.0, 0.05, uDark));\n    c *= 1.0 - rim * mix(0.12, 0.0, uDark);\n    gl_FragColor = vec4(c, 1.0);\n    #include <colorspace_fragment>\n  }`;\nconst HALO_FRAG = /* glsl */`\n  uniform vec3 uAccent; uniform float uDark; varying vec3 vN, vV;\n  float pw(float x, float e){ return pow(max(x, 0.0), e); }\n  void main(){\n    float f = smoothstep(0.0, 0.5, -dot(normalize(vN), normalize(vV)));\n    gl_FragColor = vec4(uAccent, f * f * f * mix(0.0, 0.2, uDark));\n    #include <colorspace_fragment>\n  }`;\nconst ARC_VERT = /* glsl */`varying float vT; void main(){ vT = uv.x; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;\nconst ARC_FRAG = /* glsl */`\n  uniform vec3 uAccent; uniform float uHead, uTail, uTrail; varying float vT;\n  void main(){\n    float comet = smoothstep(uHead - uTail, uHead, vT) * step(vT, uHead);\n    float trail = step(vT, uHead) * uTrail;\n    float a = max(comet, trail);\n    if (a < 0.01) discard;\n    gl_FragColor = vec4(uAccent * (1.0 + comet * 0.35), a);\n    #include <colorspace_fragment>\n  }`;\nconst NV = 'varying vec3 vN, vV; void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); vN = normalMatrix * normal; vV = -mv.xyz; gl_Position = projectionMatrix * mv; }';\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-data-globe-canvas'), tip = el.querySelector('.vs-data-globe-tip');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.setClearColor(0x000000, 0);\n\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 50);\n  const globe = new THREE.Group(); scene.add(globe);\n  const col = (n, f) => new THREE.Color(css(el, n, f));\n  const C = { ink: col('--vs-data-globe-ink', '#1d1d1b'), fill: col('--vs-data-globe-fill', '#f5f4f0'), accent: col('--vs-data-globe-accent', '#ff4a1c') };\n  const CT = { ink: C.ink.clone(), fill: C.fill.clone(), accent: C.accent.clone() };\n  const dark = new Spring(el.dataset.stage === 'dark' ? 1 : 0, 60, 15);\n\n  // ---- land dots: latitude rings, spaced by cos(lat) so the grid stays even on the sphere\n  const bytes = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0));\n  const isLand = (lat, lon) => { const i = Math.min(359, Math.max(0, Math.floor(lon + 180))), j = Math.min(179, Math.max(0, Math.floor(90 - lat))); const k = j * 360 + i; return (bytes[k >> 3] >> (k & 7)) & 1; };\n  const pos = [], seed = [];\n  const ROWS = 132;\n  for (let r = 0; r < ROWS; r++) {\n    const lat = -84 + (r + 0.5) / ROWS * 168;\n    const n = Math.max(1, Math.round(ROWS * 2 * Math.cos(THREE.MathUtils.degToRad(lat))));\n    for (let i = 0; i < n; i++) {\n      const lon = -180 + (i + (r % 2) * 0.5) / n * 360;\n      if (!isLand(lat, lon)) continue;\n      const v = toVec(lat, lon); pos.push(v.x, v.y, v.z); seed.push(Math.random());\n    }\n  }\n  const dotGeo = new THREE.BufferGeometry();\n  dotGeo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); dotGeo.setAttribute('aSeed', new THREE.Float32BufferAttribute(seed, 1));\n  const dotU = { uInk: { value: C.ink }, uSize: { value: 2 }, uScale: { value: 1 }, uAlpha: { value: 0.85 } };\n  const dots = new THREE.Points(dotGeo, new THREE.ShaderMaterial({ uniforms: dotU, vertexShader: DOT_VERT, fragmentShader: DOT_FRAG, transparent: true, depthWrite: false }));\n  dots.renderOrder = 2; globe.add(dots);\n\n  // ---- body (occludes the far side) and a thin atmosphere on the dark stage\n  const bodyU = { uFill: { value: C.fill }, uAccent: { value: C.accent }, uDark: { value: dark.x } };\n  const body = new THREE.Mesh(new THREE.SphereGeometry(0.992, 96, 64), new THREE.ShaderMaterial({ uniforms: bodyU, vertexShader: NV, fragmentShader: BODY_FRAG }));\n  globe.add(body);\n  const halo = new THREE.Mesh(new THREE.SphereGeometry(1.14, 64, 48), new THREE.ShaderMaterial({ uniforms: bodyU, vertexShader: NV, fragmentShader: HALO_FRAG, side: THREE.BackSide, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending }));\n  halo.renderOrder = -1; scene.add(halo); // drawn first; the opaque body then covers its inner half\n  // contact shadow on the light stage\n  const shadowMat = new THREE.ShaderMaterial({ transparent: true, depthWrite: false, uniforms: { uO: { value: 0.22 } },\n    vertexShader: 'varying vec2 vU; void main(){ vU = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',\n    fragmentShader: 'uniform float uO; varying vec2 vU; void main(){ float d = length((vU - 0.5) * vec2(1.0, 1.0)) * 2.0; gl_FragColor = vec4(0.0, 0.0, 0.0, uO * exp(-d * d * 4.5)); }' });\n  const shadow = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 2.6), shadowMat);\n  shadow.rotation.x = -Math.PI / 2; shadow.position.y = -1.2; shadow.scale.set(1, 0.34, 1); scene.add(shadow);\n\n  // ---- cities\n  let cities = [];\n  const cityU = { uInk: { value: C.accent }, uSize: { value: 1 }, uScale: { value: 1 }, uAlpha: { value: 1 } };\n  const cityGeo = new THREE.BufferGeometry();\n  const cityMat = new THREE.ShaderMaterial({ uniforms: cityU, transparent: true, depthWrite: false,\n    vertexShader: `uniform float uScale; attribute float aHot; varying float vA, vHot;\n      void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); float f = dot(normalize(normalMatrix * position), normalize(-mv.xyz));\n        vA = smoothstep(0.0, 0.3, f); vHot = aHot; gl_PointSize = uScale * (0.042 + 0.03 * aHot); gl_Position = projectionMatrix * mv; }`,\n    fragmentShader: `uniform vec3 uInk; varying float vA, vHot;\n      void main(){ float d = length(gl_PointCoord - 0.5) * 2.0;\n        float core = smoothstep(0.42, 0.3, d), ring = smoothstep(0.08, 0.0, abs(d - 0.78)) * vHot;\n        float a = max(core, ring) * vA; if (a < 0.01) discard;\n        vec3 c = mix(uInk, vec3(1.0), smoothstep(0.2, 0.0, d) * 0.55);\n        gl_FragColor = vec4(c, a);\n        #include <colorspace_fragment>\n      }` });\n  const cityPts = new THREE.Points(cityGeo, cityMat); cityPts.renderOrder = 4; globe.add(cityPts);\n  let hot = [];\n  function setCities(list) {\n    cities = list.map((c) => ({ ...c, v: toVec(c.lat, c.lon, 1.004), hot: new Spring(0, 220, 20) }));\n    cityGeo.setAttribute('position', new THREE.Float32BufferAttribute(cities.flatMap((c) => [c.v.x, c.v.y, c.v.z]), 3));\n    hot = new Float32Array(cities.length); cityGeo.setAttribute('aHot', new THREE.BufferAttribute(hot, 1));\n    arcs.forEach((a) => a.t = 99); // respawn every arc on the new set\n  }\n\n  // ---- arcs: great circles lifted by distance, drawn as thin tubes with a comet shader\n  const ripples = [];\n  const rippleGeo = new THREE.RingGeometry(0.8, 1, 48);\n  const arcs = [];\n  const arcGeoFor = (a, b) => {\n    const va = a.v.clone().normalize(), vb = b.v.clone().normalize();\n    const ang = va.angleTo(vb), lift = Math.min(0.26, 0.035 + ang * 0.1);\n    const pts = [];\n    for (let i = 0; i <= 64; i++) {\n      const t = i / 64, s = Math.sin(ang);\n      const p = s < 1e-4 ? va.clone() : va.clone().multiplyScalar(Math.sin((1 - t) * ang) / s).add(vb.clone().multiplyScalar(Math.sin(t * ang) / s));\n      pts.push(p.normalize().multiplyScalar(1.004 + lift * Math.sin(Math.PI * t)));\n    }\n    return { geo: new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 96, 0.0042, 6, false), dur: 1.3 + ang * 0.9 };\n  };\n  function makeArc() {\n    const U = { uAccent: { value: C.accent }, uHead: { value: 0 }, uTail: { value: 0.35 }, uTrail: { value: 0.16 } };\n    const m = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.ShaderMaterial({ uniforms: U, vertexShader: ARC_VERT, fragmentShader: ARC_FRAG, transparent: true, depthWrite: false }));\n    m.renderOrder = 3; globe.add(m);\n    return { m, U, t: 99, dur: 1, hold: 0.5, b: null, landed: false };\n  }\n  function respawn(a, delay) {\n    if (cities.length < 2) return;\n    const i = Math.floor(Math.random() * cities.length); let j = i;\n    while (j === i) j = Math.floor(Math.random() * cities.length);\n    const g = arcGeoFor(cities[i], cities[j]);\n    a.m.geometry.dispose(); a.m.geometry = g.geo; a.dur = g.dur; a.b = cities[j]; a.a = cities[i]; a.t = -delay; a.landed = false; a.hold = 0.6 + Math.random() * 0.8;\n  }\n  function ripple(c) {\n    let r = ripples.find((x) => x.t >= 1);\n    if (!r) { r = { m: new THREE.Mesh(rippleGeo, new THREE.MeshBasicMaterial({ color: C.accent, transparent: true, depthWrite: false, side: THREE.DoubleSide })), t: 1 }; r.m.renderOrder = 3; globe.add(r.m); ripples.push(r); }\n    r.t = 0; r.m.position.copy(c.v); r.m.lookAt(c.v.clone().multiplyScalar(2)); r.m.visible = true;\n  }\n  function setArcCount(n) {\n    n = Math.max(1, Math.min(24, Math.round(n))); el.dataset.arcs = n;\n    while (arcs.length < n) { const a = makeArc(); respawn(a, Math.random() * 2); arcs.push(a); }\n    while (arcs.length > n) { const a = arcs.pop(); globe.remove(a.m); a.m.geometry.dispose(); a.m.material.dispose(); }\n  }\n  setCities(CITIES);\n  setArcCount(+el.dataset.arcs || 10);\n\n  // ---- state\n  const S = { vis: true, raf: 0, last: 0, R: 1, drag: null, idle: 0, hotI: -1, touch: false, speed: parseFloat(css(el, '--vs-data-globe-speed', '1')) };\n  const yaw = { x: -1.9, v: 0 }; // lon spin, inertial\n  const pitch = new Spring(0.32, 70, 14);\n  const tipS = { x: new Spring(0, 260, 26), y: new Spring(0, 260, 26), o: new Spring(0, 200, 22) };\n  const pulse = new Spring(1, 200, 12);\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(w, h, false);\n    camera.aspect = w / h;\n    // the globe's diameter ≈ 72% of the short side\n    const fill = w < h ? 0.84 : 0.7, vh = Math.max(2.3 / fill, 2.3 / fill / camera.aspect);\n    const dist = vh / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));\n    camera.position.set(0, 0, dist); camera.lookAt(0, 0, 0); camera.updateProjectionMatrix();\n    S.R = h / vh; // css px per world unit\n    const dpr = renderer.getPixelRatio();\n    dotU.uScale.value = S.R * dpr; cityU.uScale.value = S.R * dpr;\n    dotU.uSize.value = w < 640 ? 0.0118 : 0.0105;\n  }\n\n  // ---- pointer: drag to spin with momentum; hover for labels\n  const proj = new THREE.Vector3(), wp = new THREE.Vector3(), camDir = new THREE.Vector3();\n  let mx = -1e4, my = -1e4;\n  function pick() {\n    const rect = el.getBoundingClientRect(); let best = -1, bd = S.touch ? 30 : 18;\n    globe.updateMatrixWorld();\n    cities.forEach((c, i) => {\n      wp.copy(c.v).applyMatrix4(globe.matrixWorld);\n      camDir.copy(camera.position).sub(wp).normalize();\n      if (wp.clone().normalize().dot(camDir) < 0.15) return;\n      proj.copy(wp).project(camera);\n      const sx = (proj.x * 0.5 + 0.5) * rect.width, sy = (-proj.y * 0.5 + 0.5) * rect.height;\n      const d = Math.hypot(sx - mx, sy - my); if (d < bd) { bd = d; best = i; }\n      c.sx = sx; c.sy = sy;\n    });\n    return best;\n  }\n  const onDown = (e) => {\n    S.touch = e.pointerType !== 'mouse';\n    const r = el.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top;\n    S.drag = { x: e.clientX, y: e.clientY, t: performance.now(), moved: 0 };\n    S.idle = 0;\n  };\n  const onMove = (e) => {\n    const r = el.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top;\n    if (!S.drag) return;\n    const dx = e.clientX - S.drag.x, dy = e.clientY - S.drag.y, now = performance.now(), dt = Math.max(1, now - S.drag.t) / 1000;\n    S.drag.moved += Math.abs(dx) + Math.abs(dy);\n    if (S.drag.moved > 4 && !el.classList.contains('is-dragging')) { el.classList.add('is-dragging'); try { el.setPointerCapture(e.pointerId); } catch (_) {} }\n    const k = 1 / (S.R * 1.1);\n    yaw.x += dx * k; yaw.v = yaw.v * 0.5 + dx * k / dt * 0.5; // velocity-aware release\n    pitch.t = THREE.MathUtils.clamp(pitch.t + dy * k, -0.5, 0.9); pitch.x = pitch.t;\n    S.drag.x = e.clientX; S.drag.y = e.clientY; S.drag.t = now; S.idle = 0;\n  };\n  const onUp = () => { if (S.drag && S.drag.moved < 6 && S.hotI >= 0) pulse.v = -6; S.drag = null; el.classList.remove('is-dragging'); };\n  const onLeave = (e) => { if (e.pointerType === 'mouse') { mx = my = -1e4; } };\n  const onKey = (e) => {\n    const m = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (!m) return;\n    e.preventDefault(); yaw.v += m[0] * 1.6; pitch.t = THREE.MathUtils.clamp(pitch.t + m[1] * 0.18, -0.5, 0.9); S.idle = 0;\n  };\n  el.addEventListener('pointerdown', onDown); el.addEventListener('pointermove', onMove); el.addEventListener('pointerup', onUp);\n  el.addEventListener('pointercancel', onUp); el.addEventListener('pointerleave', onLeave); el.addEventListener('keydown', onKey);\n\n  // ---- frame\n  function frame(now) {\n    S.raf = 0; if (!S.vis) return;\n    const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016); S.last = now;\n    const spd = S.speed * (reduced ? 0 : 1);\n    S.idle += dt;\n    // momentum decays toward the idle spin\n    if (!S.drag) { const base = 0.11 * spd * Math.min(1, S.idle / 1.5); yaw.v += (base - yaw.v) * (1 - Math.exp(-dt * 1.6)); yaw.x += yaw.v * dt; }\n    if (!S.drag && S.idle > 2.5) pitch.t += (0.32 - pitch.t) * (1 - Math.exp(-dt * 0.8));\n    globe.rotation.set(pitch.step(dt), yaw.x, 0, 'XYZ');\n    // colours ease between stages\n    C.ink.lerp(CT.ink, 1 - Math.exp(-dt * 6)); C.fill.lerp(CT.fill, 1 - Math.exp(-dt * 6)); C.accent.lerp(CT.accent, 1 - Math.exp(-dt * 8));\n    bodyU.uDark.value = dark.step(dt); shadowMat.uniforms.uO.value = 0.2 * (1 - bodyU.uDark.value);\n    dotU.uAlpha.value = THREE.MathUtils.lerp(0.78, 0.9, bodyU.uDark.value);\n    // arcs\n    const tdt = reduced ? 0 : dt;\n    for (const a of arcs) {\n      a.t += tdt;\n      const p = a.t / a.dur; // 0..1 draw, then hold, then retract\n      if (p < 0) { a.U.uHead.value = 0; a.U.uTrail.value = 0; a.U.uTail.value = 0.3; continue; }\n      if (p <= 1) { a.U.uHead.value = 1 - Math.pow(1 - p, 2.2); a.U.uTail.value = 0.35; a.U.uTrail.value = 0.18; }\n      else {\n        if (!a.landed) { a.landed = true; ripple(a.b); }\n        const q = (a.t - a.dur - a.hold) / 0.9;\n        a.U.uHead.value = 1; a.U.uTail.value = 0.35; a.U.uTrail.value = 0.18 * (1 - THREE.MathUtils.clamp(q, 0, 1));\n        if (q > 0) { a.U.uTail.value = 0.35 * Math.max(0, 1 - q); }\n        if (q >= 1) respawn(a, Math.random() * 0.8);\n      }\n    }\n    if (reduced) arcs.forEach((a) => { a.U.uHead.value = 1; a.U.uTrail.value = 0.3; a.U.uTail.value = 0.35; });\n    for (const r of ripples) { if (r.t >= 1) { r.m.visible = false; continue; } r.t = Math.min(1, r.t + dt / 1.1); const e = 1 - Math.pow(1 - r.t, 3); r.m.scale.setScalar(0.012 + e * 0.07); r.m.material.opacity = (1 - r.t) * 0.9; r.m.material.color.copy(C.accent); }\n    // hover\n    const hi = S.drag && S.drag.moved > 6 ? -1 : pick();\n    if (hi !== S.hotI) { S.hotI = hi; el.classList.toggle('is-hot', hi >= 0); if (hi >= 0) { const c = cities[hi]; tip.querySelector('b').textContent = c.name; tip.querySelector('span').textContent = `${Math.abs(c.lat).toFixed(2)}° ${c.lat >= 0 ? 'N' : 'S'} · ${Math.abs(c.lon).toFixed(2)}° ${c.lon >= 0 ? 'E' : 'W'}`; tipS.x.x = c.sx; tipS.y.x = c.sy; pulse.v = -3; } }\n    cities.forEach((c, i) => { c.hot.t = i === S.hotI ? 1 : 0; hot[i] = c.hot.step(dt) * (i === S.hotI ? pulse.step(dt) : 1); });\n    cityGeo.attributes.aHot.needsUpdate = true;\n    tipS.o.t = S.hotI >= 0 ? 1 : 0;\n    if (S.hotI >= 0) { const c = cities[S.hotI]; tipS.x.t = c.sx; tipS.y.t = c.sy; }\n    const tx = tipS.x.step(dt), ty = tipS.y.step(dt), to = Math.max(0, Math.min(1, tipS.o.step(dt)));\n    tip.style.opacity = to.toFixed(3);\n    const flip = tx > el.clientWidth - 190 ? -1 : 1;\n    tip.style.transform = `translate3d(${(tx + (flip > 0 ? 16 : -16)).toFixed(1)}px, ${(ty - 20).toFixed(1)}px, 0) translateX(${flip > 0 ? 0 : -100}%) scale(${(0.92 + 0.08 * to).toFixed(3)})`;\n    renderer.render(scene, camera);\n    loop();\n  }\n  const loop = () => { if (!S.raf && S.vis) S.raf = requestAnimationFrame(frame); };\n\n  const ro = new ResizeObserver(resize); ro.observe(el); resize();\n  const io = new IntersectionObserver(([e]) => { S.vis = e.isIntersecting; if (S.vis) { S.last = 0; loop(); } }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { S.vis = !document.hidden; if (S.vis) { S.last = 0; loop(); } };\n  document.addEventListener('visibilitychange', onVis);\n  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.vis = false; });\n  canvas.addEventListener('webglcontextrestored', () => { S.vis = true; S.last = 0; resize(); loop(); });\n  renderer.compile(scene, camera);\n  requestAnimationFrame(() => { el.dataset.vsState = 'ready'; loop(); });\n\n  function set(o = {}) {\n    if (o.stage) { el.dataset.stage = o.stage; dark.t = o.stage === 'dark' ? 1 : 0; requestAnimationFrame(() => { CT.ink.set(css(el, '--vs-data-globe-ink', '#1d1d1b')); CT.fill.set(css(el, '--vs-data-globe-fill', '#f5f4f0')); }); }\n    if (o.accent) { el.style.setProperty('--vs-data-globe-accent', o.accent); CT.accent.set(o.accent); }\n    if (o.arcs != null) setArcCount(o.arcs);\n    if (o.speed != null) { S.speed = +o.speed; el.style.setProperty('--vs-data-globe-speed', o.speed); }\n    if (o.cities) setCities(o.cities);\n  }\n  function destroy() {\n    cancelAnimationFrame(S.raf); S.vis = false; ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'pointerleave', 'keydown'].forEach((t, i) => el.removeEventListener(t, [onDown, onMove, onUp, onUp, onLeave, onKey][i]));\n    scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });\n    renderer.dispose(); delete el.vsDataGlobe;\n  }\n  // screen position of any lat/lon (for your own HTML labels); visible = on the near side\n  function project(lat, lon) {\n    globe.updateMatrixWorld(); const w = toVec(lat, lon, 1.004).applyMatrix4(globe.matrixWorld);\n    const vis = w.clone().normalize().dot(camera.position.clone().sub(w).normalize()) > 0.15; const p = w.clone().project(camera);\n    return { x: (p.x * 0.5 + 0.5) * el.clientWidth, y: (-p.y * 0.5 + 0.5) * el.clientHeight, visible: vis };\n  }\n  return { set, project, destroy };\n}\n\ndocument.querySelectorAll('.vs-data-globe').forEach((el) => { if (!el.vsDataGlobe) { const api = mount(el); if (api) el.vsDataGlobe = api; } });\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "globe",
      "map",
      "arcs",
      "data",
      "network",
      "dots",
      "drag",
      "three.js"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": null,
    "free": true,
    "accent": "#ff4a1c",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/data-globe/index.html",
    "thumbnail": "https://vividsites.app/api/component/data-globe/thumb.jpg",
    "video": "https://vividsites.app/api/component/data-globe/thumb.mp4",
    "page": "https://vividsites.app/c/data-globe"
  }
}