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
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
Property Value Description widthDynamic Container width heightDynamic Container height globeImageUrlearth-dark.jpg Dark-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 . 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:
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 );
}
});
Why use isProgrammaticMove flag?
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
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
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:
State Color Altitude Condition Selected #c8a46e (gold)0.02 User clicked the country Has Mates #6A8F60 (green)0.012 At least one mate submission Empty rgba(255,255,255,.04)0 No mates yet
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:
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:
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:
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:
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
Property Value Description arcColorGreen → Gold gradient Start and end colors arcAltitudeAutoScale0.4 Arc height as fraction of distance arcStroke0.5 Line thickness arcDashLength0.4 Dash length (0-1) arcDashGap0.2 Gap between dashes arcDashAnimateTime2500ms Animation duration Interval 2500ms Time 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.
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.
Conditional Auto-Rotation
Auto-rotation only resumes when the user is not zoomed in (altitude >= 1.8).
Browser Compatibility
Globe.gl requires WebGL support:
Browser Minimum Version Chrome 56+ Firefox 52+ Safari 11+ Edge 79+ Mobile Safari 11+ Chrome Android 87+
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