The 200 Mates application is organized into eight specialized modules, each handling a specific aspect of functionality. All modules are loaded as separate script files in index.html and share global state.
Module Overview
state.js Global state management
utils.js Helper functions and utilities
globe.js 3D globe visualization
gps.js GPS location detection
form.js Mate submission form
gallery.js Photo gallery and stats
modal.js Lightbox and success modal
menu.js Dropdown menu navigation
state.js
Purpose : Centralized global state for the entire application.
Location : modules/state.js
State Variables
let allMates = []; // Array of all approved mate submissions
let countriesColored = []; // Array of ISO3 country codes with mates
let countriesGeo = []; // Array of GeoJSON feature objects
let arcInterval = null ; // setInterval ID for arc animations
let polyTimer = null ; // setTimeout ID for polygon debouncing
let lastMarkerIds = "" ; // Cache key for marker rendering
let currentLang = "es" ; // Current language ("es", "en", or "pt")
All state variables use let declarations to allow modification from any module. This is a deliberate design choice for simplicity in a small application.
Dependencies : None
Used By : All other modules
utils.js
Purpose : Provides utility functions for translation, HTML escaping, coordinate resolution, and photo URL generation.
Location : modules/utils.js
Functions
t(key)
Translates a key to the current language.
function t ( key ) {
return window . i18n ?.[ currentLang ]?.[ key ] ??
window . i18n ?. es ?.[ key ] ??
key ;
}
Parameters:
key (string): Translation key from i18n.js
Returns : Translated string, fallback to Spanish, or key if not found
Example:
t ( "title" ) // → "Vuelta al Mundo en unos 200 Mates" (if currentLang = "es")
applyI18n()
Updates all DOM elements with data-i18n or data-i18n-placeholder attributes.
function applyI18n () {
document . querySelectorAll ( "[data-i18n]" ). forEach ( el => {
const v = t ( el . dataset . i18n );
if ( v && v !== el . dataset . i18n ) el . innerHTML = v ;
});
document . querySelectorAll ( "[data-i18n-placeholder]" ). forEach ( el => {
const v = t ( el . dataset . i18nPlaceholder );
if ( v ) el . placeholder = v ;
});
renderGallery ();
}
Parameters : None
Returns : None (mutates DOM)
esc(s)
Escapes HTML special characters to prevent XSS.
function esc ( s ) {
const d = document . createElement ( "div" );
d . textContent = s || "" ;
return d . innerHTML ;
}
Parameters:
s (string): Raw string to escape
Returns : HTML-escaped string
Example:
esc ( "<script>alert('xss')</script>" )
// → "<script>alert('xss')</script>"
displayId(m)
Returns the display ID for a mate submission.
function displayId ( m ) {
return m . public_id || String ( m . id );
}
Parameters:
m (object): Mate object from database
Returns : Public ID if available, otherwise database ID
resolveCoords(m)
Resolves latitude/longitude for a mate, falling back to capital coordinates if GPS data is missing.
function resolveCoords ( m ) {
if ( m . lat != null && m . lng != null )
return { lat: m . lat , lng: m . lng };
const iso3 = ( m . country_code ?. trim (). toUpperCase ()) ||
( m . country && nameToIso3 [ m . country . toLowerCase (). trim ()]);
return iso3 && capitalCoords [ iso3 ] ? capitalCoords [ iso3 ] : null ;
}
Parameters:
Returns : { lat, lng } or null
Logic:
If mate has GPS coordinates, use them
Otherwise, look up country code or name
Return capital city coordinates for that country
Return null if no coordinates found
getPhotoUrl(path)
Generates public URL for a photo in the Supabase storage bucket.
function getPhotoUrl ( path ) {
if ( ! path ) return null ;
const { data } = sb . storage . from ( "mate-photos" ). getPublicUrl ( path );
return data ?. publicUrl || null ;
}
Parameters:
path (string): File path in the mate-photos bucket
Returns : Public URL string or null
globe.js
Purpose : Manages the 3D globe visualization, including polygons, markers, and arcs.
Location : modules/globe.js
Key Functions
renderPolygons()
Colors country polygons based on mate submissions.
function renderPolygons () {
if ( ! countriesGeo . length ) return ;
if ( polyTimer ) clearTimeout ( polyTimer );
polyTimer = setTimeout (() => {
const colored = [ ... countriesColored ], sel = selectedIso3 ;
globe
. polygonsData ([])
. polygonCapColor ( d =>
sel && d . iso3 === sel ? "#c8a46e" : // Gold for selected
colored . includes ( d . iso3 ) ? "#6A8F60" : // Green for mates
"rgba(255,255,255,.04)" // Transparent for empty
)
. 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 );
}
Rendering is debounced by 60ms to prevent excessive redraws during rapid state changes.
applyJitter(mates)
Disposes overlapping markers in a circular pattern.
function applyJitter ( mates ) {
const groups = {};
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 ; }
g . forEach (( m , i ) => {
const a = ( i / g . length ) * 2 * Math . PI ;
out . push ({
... m ,
lat: m . lat + 1.2 * Math . cos ( a ),
lng: m . lng + 1.2 * Math . sin ( a )
});
});
});
return out ;
}
Algorithm:
Group markers by rounded coordinates (0.1° precision)
For groups with multiple markers, arrange in a circle
Circle radius: 1.2°
Angle for marker i: (i / groupSize) * 2π
renderMarkers(mates)
Creates HTML element markers on the globe with hover cards.
Features:
🧉 Emoji markers
Hover cards with photo and details
Smart positioning (above or below marker)
Click to open lightbox
renderArcs(mates)
Animates arcs connecting consecutive mate submissions.
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
}]);
i ++ ;
}
next ();
arcInterval = setInterval ( next , 2500 );
}
Behavior:
Cycles through mate pairs every 2.5 seconds
Dash animation: 0.4 length, 0.2 gap, 2500ms duration
Color gradient: green to gold
Purpose : Handles mate submission form, including preparation toggles, photo preview, and GPS integration.
Location : modules/form.js
Key Features
Preparation Toggle
Switches between amargo, dulce, tereré, and mate cocido.
Photo Upload
Drag-and-drop or click to upload with instant preview.
Form Submission
Uploads photo to Supabase storage, inserts record to database, animates globe to new location.
document . getElementById ( "mateForm" ). addEventListener ( "submit" , async e => {
e . preventDefault ();
// 1. Gather form data
const name = document . getElementById ( "name" ). value . trim ();
const country = document . getElementById ( "country" ). value . trim ();
const brand = document . getElementById ( "brand" ). value . trim ();
const preparation = document . getElementById ( "preparation" ). value ;
const mate_type = document . getElementById ( "mate_type" ). value . trim ();
const photo = document . getElementById ( "photo" ). files [ 0 ];
let lat = parseFloat ( document . getElementById ( "lat" ). value );
let lng = parseFloat ( document . getElementById ( "lng" ). value );
// 2. Validate required fields
if ( ! name || ! country || ! brand || ! photo ) {
alert ( t ( "alertRequired" ));
return ;
}
// 3. Upload photo
const ext = photo . name . split ( "." ). pop ();
const fileName = ` ${ Date . now () } _ ${ Math . random (). toString ( 36 ). slice ( 2 ) } . ${ ext } ` ;
const { error : ue } = await sb . storage
. from ( "mate-photos" )
. upload ( fileName , photo , { contentType: photo . type });
if ( ue ) throw ue ;
// 4. Insert database record
const { error : ie } = await sb . from ( "mates" ). insert ([{
name , country , country_code: countryCode || null ,
brand , preparation ,
mate_type: mate_type || null ,
photo_path: fileName ,
lat: isNaN ( lat ) ? null : lat ,
lng: isNaN ( lng ) ? null : lng ,
approved: null // Pending moderation
}]);
if ( ie ) throw ie ;
// 5. Animate globe
globe . pointOfView ({ lat , lng , altitude: 0.8 }, 1500 );
setTimeout (() => {
globe . pointOfView ({ lat: 20 , lng: - 20 , altitude: 2.5 }, 1500 );
}, 4000 );
// 6. Show success modal
showSuccessModal ();
e . target . reset ();
});
gallery.js
Purpose : Renders recent mates in the sidebar gallery and manages the lightbox.
Location : modules/gallery.js
Functions
renderGallery()
Displays the 3 most recent mates with staggered animation.
function renderGallery () {
const el = document . getElementById ( "gallery" );
if ( ! el ) return ;
const recent = allMates . slice ( 0 , 3 );
if ( ! recent . length ) {
el . innerHTML = `<p class="no-mates"> ${ t ( "noMates" ) } </p>` ;
return ;
}
el . innerHTML = recent . map (( m , i ) => {
const url = getPhotoUrl ( m . photo_path );
return `<div class="gallery-card" style="animation-delay: ${ i * .08 } s">
<img src=" ${ esc ( url || "placeholder.png" ) } " alt="mate">
<div class="gallery-info">
<span class="gallery-name"> ${ esc ( m . name || t ( "anonymous" )) } </span>
<span class="gallery-country"> ${ esc ( m . country || "" ) } </span>
</div>
</div>` ;
}). join ( "" );
}
updateStats()
Animates the stats counter in the header.
function updateStats () {
animateCount ( document . getElementById ( "statMates" ), allMates . length );
animateCount ( document . getElementById ( "statCountries" ), countriesColored . length );
}
openLightbox(d)
Displays a mate in a full-screen lightbox with photo, details, and copy-ID button.
gps.js
Purpose : Requests GPS permissions and detects user location for auto-filling the country field.
Location : modules/gps.js
Key Features
Requests navigator.geolocation.getCurrentPosition()
Reverse geocodes coordinates to country using Nominatim OpenStreetMap API
Updates form with detected country and coordinates
Shows status messages in multiple languages
Handles country selection via globe click or manual input
Functions
initGPS()
Requests GPS permission and detects user location.
function initGPS () {
const statusEl = document . getElementById ( "gpsStatus" );
const latIn = document . getElementById ( "lat" );
const lngIn = document . getElementById ( "lng" );
if ( ! navigator . geolocation ) {
statusEl . textContent = t ( "gpsNoGps" );
return ;
}
navigator . geolocation . getCurrentPosition ( pos => {
latIn . value = pos . coords . latitude . toFixed ( 2 );
lngIn . value = pos . coords . longitude . toFixed ( 2 );
// Reverse geocode with Nominatim API
fetch (
`https://nominatim.openstreetmap.org/reverse?lat= ${ pos . coords . latitude } &lon= ${ pos . coords . longitude } &format=json` ,
{ headers: { "User-Agent" : "200Mates/1.0" } }
)
. then ( r => r . json ())
. then ( d => {
const cc = d ?. address ?. country_code ?. toUpperCase ();
const iso3 = iso2ToIso3 [ cc ] || "" ;
// Auto-fill country field
});
});
}
External Dependency : Nominatim OpenStreetMap API
The Nominatim API requires a User-Agent header. The app uses "200Mates/1.0" to identify requests.
selectCountry(f)
Selects a country when user clicks a polygon on the globe.
function selectCountry ( f ) {
const iso3 = f . iso3 ;
if ( ! iso3 ) return ;
selectedIso3 = iso3 ;
// Auto-fill country field
const countryIn = document . getElementById ( "country" );
const opt = document . querySelector ( `#country-list option[data-iso3=" ${ iso3 } "]` );
if ( opt && countryIn ) {
countryIn . value = opt . value ;
}
// Set coordinates to capital
const cap = capitalCoords [ iso3 ];
if ( cap ) {
document . getElementById ( "lat" ). value = parseFloat ( cap . lat . toFixed ( 2 ));
document . getElementById ( "lng" ). value = parseFloat ( cap . lng . toFixed ( 2 ));
// Animate globe to country
globe . pointOfView ({ lat: cap . lat , lng: cap . lng , altitude: 0.8 }, 800 );
}
renderPolygons (); // Update country highlighting
}
Triggered by : Globe polygon click handler in globe.js
modal.js
Purpose : Success modal with animated mate emoji and steam effect.
Location : modules/modal.js
Functions
showSuccessModal()
Displays the success modal after mate submission.
function showSuccessModal () {
const modal = document . getElementById ( "successModal" );
if ( ! modal ) return ;
// Update text with translations
const titleEl = modal . querySelector ( ".smodal-title" );
const body1El = modal . querySelector ( ".smodal-body1" );
const body2El = modal . querySelector ( ".smodal-body2" );
const btnEl = modal . querySelector ( ".smodal-btn" );
if ( titleEl ) titleEl . textContent = t ( "successTitle" );
if ( body1El ) body1El . textContent = t ( "successBody1" );
if ( body2El ) body2El . textContent = t ( "successBody2" );
if ( btnEl ) btnEl . textContent = t ( "successBtn" );
modal . classList . add ( "open" );
// Animate steam particles
const steam = modal . querySelector ( ".smodal-steam" );
if ( steam ) {
steam . innerHTML = "" ;
for ( let i = 0 ; i < 6 ; i ++ ) {
const p = document . createElement ( "span" );
p . className = "steam-particle" ;
p . style . cssText = `left: ${ 30 + i * 8 } %;animation-delay: ${ i * 0.3 } s;animation-duration: ${ 1.8 + i * 0.2 } s` ;
steam . appendChild ( p );
}
}
}
Features:
Translates modal text to current language
Creates 6 animated steam particles above mate emoji
Staggered animation delays for visual effect
hideSuccessModal()
Closes the success modal.
function hideSuccessModal () {
const modal = document . getElementById ( "successModal" );
if ( modal ) modal . classList . remove ( "open" );
}
Triggered by:
Click on modal backdrop
Click on “OK” button
ESC key press (via menu.js)
Purpose : Hamburger menu with tabs for About, Support, Moderation, Press, FAQs, Terms, Privacy, and Contact.
Location : modules/menu.js
Features
Dropdown panel with tab navigation
Backdrop overlay
Smooth transitions
Accessible with ARIA attributes
Module Dependencies
state.js (no dependencies)
↓
utils.js
↓
┌──────────┬──────────┬──────────┐
│ │ │ │
globe.js form.js gallery.js gps.js menu.js modal.js
Next Steps
Globe Visualization Deep dive into Globe.gl integration
Data Structures Understand data models and schemas