Skip to main content
The 200 Mates globe visualization is powered by Globe.gl, a WebGL-based library built on Three.js. This page covers the technical implementation details.

Globe.gl Overview

Technology

Three.js + WebGL for 3D rendering

Performance

Hardware-accelerated GPU rendering

Features

Polygons, markers, arcs, labels, and more

Interactive

Mouse/touch controls with orbit camera

Globe Initialization

globe.js:5
const globeEl = document.getElementById("globe");

function getGlobeDimensions() {
  const s = globeEl.closest(".globe-section") || globeEl.parentElement;
  return { w: s?.clientWidth || 600, h: s?.clientHeight || 600 };
}

const { w: initW, h: initH } = getGlobeDimensions();
globeEl.style.width  = initW + "px";
globeEl.style.height = initH + "px";

const globe = Globe()(globeEl)
  .width(initW)
  .height(initH)
  .globeImageUrl("//unpkg.com/three-globe/example/img/earth-dark.jpg")
  .backgroundColor("#000000")
  .pointOfView({ lat: 20, lng: -20, altitude: 2.5 });

Configuration

PropertyValueDescription
widthDynamicContainer width
heightDynamicContainer height
globeImageUrlearth-dark.jpgDark-themed Earth texture
backgroundColor#000000Black space background
pointOfView{lat: 20, lng: -20, altitude: 2.5}Initial camera position
The dark Earth texture (earth-dark.jpg) provides better contrast for the green country polygons and mate markers.

Camera Controls

globe.js:22
globe.controls().autoRotate      = true;
globe.controls().autoRotateSpeed = 0.4;
globe.controls().minPolarAngle   = Math.PI * 0.2;  // 36° from north pole
globe.controls().maxPolarAngle   = Math.PI * 0.8;  // 36° from south pole
globe.controls().enablePan       = false;

Auto-Rotation System

The globe automatically rotates until the user interacts with it:
globe.js:28
let rotateTimer;
let isZoomed = false;
let isProgrammaticMove = false;

globe.controls().addEventListener("change", () => {
  if (isProgrammaticMove) return;
  const alt = globe.pointOfView().altitude;
  
  // Stop rotation when zoomed in
  if (alt < ZOOM_THRESHOLD && !isZoomed) {
    isZoomed = true;
    globe.controls().autoRotate = false;
  } 
  // Resume rotation when zoomed out
  else if (alt >= ZOOM_THRESHOLD && isZoomed) {
    isZoomed = false;
    clearTimeout(rotateTimer);
    rotateTimer = setTimeout(() => { 
      globe.controls().autoRotate = true; 
    }, 800);
  }
});
When the application programmatically moves the camera (e.g., after submitting a mate), we set isProgrammaticMove = true to prevent the auto-rotation logic from interfering. This prevents the camera from immediately resuming rotation during the animated zoom sequence.

User Interaction Handlers

globe.js:45
globeEl.addEventListener("mousedown", () => {
  clearTimeout(rotateTimer);
  globe.controls().autoRotate = false;
});

globeEl.addEventListener("touchstart", () => {
  clearTimeout(rotateTimer);
  globe.controls().autoRotate = false;
});

["mouseup", "touchend"].forEach(ev => 
  globeEl.addEventListener(ev, () => {
    if (!isZoomed) {
      clearTimeout(rotateTimer);
      rotateTimer = setTimeout(() => { 
        globe.controls().autoRotate = true; 
      }, 2000);
    }
  })
);

globeEl.addEventListener("mouseleave", () => {
  if (!isZoomed) {
    clearTimeout(rotateTimer);
    rotateTimer = setTimeout(() => { 
      globe.controls().autoRotate = true; 
    }, 1200);
  }
});
Behavior:
  • Auto-rotation stops on mousedown/touchstart
  • Resumes 2 seconds after mouseup/touchend (if not zoomed)
  • Resumes 1.2 seconds after mouseleave (if not zoomed)

Responsive Sizing

globe.js:67
window.addEventListener("resize", () => {
  const { w, h } = getGlobeDimensions();
  globeEl.style.width = w + "px";
  globeEl.style.height = h + "px";
  globe.width(w).height(h);
});
The globe automatically resizes to fill its container on window resize events.

Polygon Rendering

Color States

Countries are colored based on their relationship to the mate data:
StateColorAltitudeCondition
Selected#c8a46e (gold)0.02User clicked the country
Has Mates#6A8F60 (green)0.012At least one mate submission
Emptyrgba(255,255,255,.04)0No mates yet
globe.js:74
function renderPolygons() {
  if (!countriesGeo.length) return;
  if (polyTimer) clearTimeout(polyTimer);
  
  polyTimer = setTimeout(() => {
    const colored = [...countriesColored];
    const sel = selectedIso3;
    
    globe
      .polygonsData([])  // Clear first to force re-render
      .polygonCapColor(d =>
        sel && d.iso3 === sel    ? "#c8a46e" :
        colored.includes(d.iso3) ? "#6A8F60" :
        "rgba(255,255,255,.04)"
      )
      .polygonSideColor(() => "rgba(0,0,0,.2)")
      .polygonStrokeColor(() => "#2a2e24")
      .polygonAltitude(d =>
        sel && d.iso3 === sel    ? .02 :
        colored.includes(d.iso3) ? .012 :
        0
      )
      .onPolygonClick(p => {
        clearTimeout(rotateTimer);
        selectCountry(p);
      })
      .onPolygonHover(h => { 
        globeEl.style.cursor = h ? "pointer" : "default"; 
      })
      .polygonsData(countriesGeo.slice());
  }, 60);
}
The 60ms debounce prevents excessive re-renders when state changes rapidly (e.g., during data loading or country selection).

GeoJSON Data Source

Country boundaries come from the World Atlas TopoJSON library:
app.js:59
fetch("https://unpkg.com/world-atlas@2/countries-110m.json")
  .then(r => r.ok ? r.json() : Promise.reject())
  .then(world => {
    const raw = topojson.feature(world, world.objects.countries).features;
    countriesGeo = raw.map(f => ({
      ...f, 
      iso3: numericToIso3[String(parseInt(f.id, 10))] || null
    }));
    renderPolygons();
  });
The TopoJSON is converted to GeoJSON features and enriched with ISO3 country codes for lookup.

Marker System

HTML Element Markers

Unlike standard Globe.gl point markers, 200 Mates uses HTML element markers for rich interactivity:
globe.js:121
function renderMarkers(mates) {
  const dispersed = applyJitter(mates);
  
  globe
    .htmlElementsData(dispersed)
    .htmlLat(d => d.lat)
    .htmlLng(d => d.lng)
    .htmlAltitude(0.012)
    .htmlTransitionDuration(0)
    .htmlElement(d => {
      const photoUrl = getPhotoUrl(d.photo_path);
      const wrapper = document.createElement("div");
      wrapper.style.cssText = "position:relative;display:flex;flex-direction:column;align-items:center;pointer-events:auto;";
      
      // Create hover card
      const card = document.createElement("div");
      card.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%) scale(.85);opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;background:rgba(10,12,8,.96);border:1px solid rgba(138,173,94,.35);border-radius:12px;overflow:hidden;width:160px;box-shadow:0 8px 32px rgba(0,0,0,.8);z-index:9999;`;
      
      // Add photo to card
      if (photoUrl) {
        const img = document.createElement("img");
        img.src = photoUrl;
        img.style.cssText = "width:160px;max-height:120px;object-fit:contain;display:block;background:#000;";
        img.onerror = () => img.style.display = "none";
        card.appendChild(img);
      }
      
      // Add info to card
      const info = document.createElement("div");
      info.style.cssText = "padding:8px 10px;";
      info.innerHTML = `
        <div style="font-family:'DM Sans',sans-serif;font-size:12px;font-weight:600;color:#e8e4da;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.name || t("anonymous"))}</div>
        ${d.country ? `<div style="font-size:10px;color:#9a9888;margin-top:2px">🌍 ${esc(d.country)}</div>` : ""}
        ${d.brand ? `<div style="font-size:10px;color:#8aad5e;margin-top:2px">🌿 ${esc(d.brand)}</div>` : ""}
        <div style="font-size:9px;color:#5a5a4e;margin-top:3px"># ${esc(displayId(d))}</div>
      `;
      card.appendChild(info);
      
      // Create emoji marker
      const emoji = document.createElement("div");
      emoji.textContent = "🧉";
      emoji.style.cssText = "font-size:22px;cursor:pointer;transition:transform .15s;user-select:none;filter:drop-shadow(0 2px 4px rgba(0,0,0,.6))";
      
      wrapper.appendChild(card);
      wrapper.appendChild(emoji);
      
      // Hover effects
      wrapper.addEventListener("mouseenter", () => {
        clearTimeout(rotateTimer);
        const rect = wrapper.getBoundingClientRect();
        const cardHeight = 175;
        const spaceAbove = rect.top - 80;
        
        // Smart positioning: show card above if not enough space below
        if (spaceAbove < cardHeight) {
          card.style.bottom = "auto";
          card.style.top = "36px";
        } else {
          card.style.bottom = "36px";
          card.style.top = "auto";
        }
        
        card.style.opacity = "1";
        card.style.transform = "translateX(-50%) scale(1)";
        emoji.style.transform = "scale(1.35)";
        globe.controls().autoRotate = false;
      });
      
      wrapper.addEventListener("mouseleave", () => {
        card.style.opacity = "0";
        card.style.transform = "translateX(-50%) scale(.85)";
        emoji.style.transform = "scale(1)";
        
        if (!isZoomed) {
          clearTimeout(rotateTimer);
          rotateTimer = setTimeout(() => { 
            globe.controls().autoRotate = true; 
          }, 1200);
        }
      });
      
      wrapper.addEventListener("click", () => openLightbox(d));
      
      return wrapper;
    });
}
HTML element markers are more resource-intensive than point markers. For 200+ markers, consider switching to points with click handlers for better performance.

Jitter Algorithm

To prevent overlapping markers at the same location, a jitter algorithm disperses them in a circle:
globe.js:103
function applyJitter(mates) {
  const groups = {};
  
  // Group by rounded coordinates (0.1° precision)
  mates.forEach((m, i) => {
    const k = `${m.lat.toFixed(1)},${m.lng.toFixed(1)}`;
    if (!groups[k]) groups[k] = [];
    groups[k].push({ ...m, _uid: `${m.id}_${i}` });
  });
  
  const out = [];
  Object.values(groups).forEach(g => {
    if (g.length === 1) { 
      out.push(g[0]); 
      return; 
    }
    
    // Arrange multiple markers in a circle
    g.forEach((m, i) => {
      const angle = (i / g.length) * 2 * Math.PI;
      out.push({ 
        ...m, 
        lat: m.lat + 1.2 * Math.cos(angle), 
        lng: m.lng + 1.2 * Math.sin(angle) 
      });
    });
  });
  
  return out;
}
Parameters:
  • Grid precision: 0.1° (≈ 11 km at equator)
  • Circle radius: 1.2° (≈ 133 km at equator)
Example: If 3 mates are submitted from Buenos Aires:
  • Mate 1: Original position
  • Mate 2: +1.2° at 120° angle
  • Mate 3: +1.2° at 240° angle

Arc Animations

Arcs connect consecutive mate submissions in chronological order:
globe.js:192
function renderArcs(mates) {
  if (arcInterval) { 
    clearInterval(arcInterval); 
    arcInterval = null; 
  }
  if (mates.length < 2) { 
    globe.arcsData([]); 
    return; 
  }
  
  let i = 0;
  function next() {
    const idx = i % (mates.length - 1);
    globe
      .arcsData([{ 
        startLat: mates[idx].lat, 
        startLng: mates[idx].lng, 
        endLat: mates[idx+1].lat, 
        endLng: mates[idx+1].lng 
      }])
      .arcColor(() => ["rgba(106,143,96,.6)", "rgba(200,164,110,.6)"])  // Green to gold gradient
      .arcAltitudeAutoScale(0.4)
      .arcStroke(0.5)
      .arcDashLength(0.4)
      .arcDashGap(0.2)
      .arcDashAnimateTime(2500);
    i++;
  }
  
  next();
  arcInterval = setInterval(next, 2500);
}

Arc Properties

PropertyValueDescription
arcColorGreen → Gold gradientStart and end colors
arcAltitudeAutoScale0.4Arc height as fraction of distance
arcStroke0.5Line thickness
arcDashLength0.4Dash length (0-1)
arcDashGap0.2Gap between dashes
arcDashAnimateTime2500msAnimation duration
Interval2500msTime between arc changes
Only one arc is rendered at a time to minimize GPU load. The arc cycles through all mate connections every 2.5 seconds.

Performance Optimization

Markers are only re-rendered when lastMarkerIds changes:
const ids = matesWithCoords.map(m => m.id).join(",");
if (ids !== lastMarkerIds) {
  lastMarkerIds = ids;
  renderMarkers(matesWithCoords);
}
Polygon rendering is debounced by 60ms to prevent rapid redraws during state changes.
Only one arc is rendered at a time, cycling through connections on an interval.
Auto-rotation only resumes when the user is not zoomed in (altitude >= 1.8).

Browser Compatibility

Globe.gl requires WebGL support:
BrowserMinimum Version
Chrome56+
Firefox52+
Safari11+
Edge79+
Mobile Safari11+
Chrome Android87+
Older browsers without WebGL support will not be able to render the globe. Consider adding a fallback static map for unsupported browsers.

Next Steps

Data Structures

Understand the mate and country data formats

Modules

Explore the modular architecture

Build docs developers (and LLMs) love