Skip to main content
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

state.js
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.
utils.js:5
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.
utils.js:9
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.
utils.js:21
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>")
// → "&lt;script&gt;alert('xss')&lt;/script&gt;"

displayId(m)

Returns the display ID for a mate submission.
utils.js:25
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.
utils.js:29
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:
  • m (object): Mate object
Returns: { lat, lng } or null Logic:
  1. If mate has GPS coordinates, use them
  2. Otherwise, look up country code or name
  3. Return capital city coordinates for that country
  4. Return null if no coordinates found

getPhotoUrl(path)

Generates public URL for a photo in the Supabase storage bucket.
utils.js:36
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.
globe.js:74
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.
globe.js:103
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:
  1. Group markers by rounded coordinates (0.1° precision)
  2. For groups with multiple markers, arrange in a circle
  3. Circle radius: 1.2°
  4. 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.
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 
    }]);
    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

form.js

Purpose: Handles mate submission form, including preparation toggles, photo preview, and GPS integration. Location: modules/form.js

Key Features

1

Preparation Toggle

Switches between amargo, dulce, tereré, and mate cocido.
2

Photo Upload

Drag-and-drop or click to upload with instant preview.
3

Form Submission

Uploads photo to Supabase storage, inserts record to database, animates globe to new location.

Form Submission Flow

form.js:34
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();
});

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.
gallery.js:26
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.
gallery.js:20
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.
gps.js:7
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.
gps.js:90
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
Purpose: Success modal with animated mate emoji and steam effect. Location: modules/modal.js

Functions

showSuccessModal()

Displays the success modal after mate submission.
modal.js:5
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.
modal.js:32
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

Build docs developers (and LLMs) love