The Problem: Why I Built This

As someone who travels but avoids social media, I needed a way to share my experiences that was:

  • Not Controlled: No algorithms deciding who sees what
  • Performant: Should work smoothly even on slow connections
  • Cost-effective: Must run within free tier limits
  • Visually interesting: More engaging than a standard photo grid

The constraints led to some interesting technical challenges that made this more than just another portfolio component.

Design Decisions

Why a 3D Globe?

I evaluated several mapping approaches before choosing a 3D globe. Options like Google Maps Embed, Static Image Maps, and 2D SVG maps didn’t offer the uniqueness or interactivity I desired. In the end, a 3D Globe — interactive and visually striking — was chosen.

Option Pros Cons Why Rejected
Google Maps Embed Easy implementation Generic look, API limits Too common
Static Image Map Lightweight No interactivity Too boring
2D SVG Map Customizable Flat representation Lacked dynamic flair
3D Globe Interactive, visually striking More complex to implement Chosen

Technical Stack Selection

After evaluating libraries, I focused on Globe.gl for its balance of features and ease-of-use. Below is a sample evaluation:

// Evaluation of 3D visualization libraries
const globeLibraries = [
  {
    name: "Three.js",
    pros: ["Low-level control", "Customizable shaders", "Massive ecosystem"],
    cons: ["Requires deep WebGL knowledge", "No built-in globe tools"]
  },
  {
    name: "Globe.gl",
    pros: ["Quick to set up", "Great for 3D globes", "Built on Three.js"],
    cons: ["Limited to globe-centric use cases", "Less control over internals"]
  },
  {
    name: "CesiumJS",
    pros: ["Advanced geospatial features", "Real-world terrain data"],
    cons: ["Heavy bundle size", "Too complex for a visual photo journal"]
  }
];

// Final decision
const selectedLibrary = "Globe.gl"; // Balanced, quick to deploy, visually striking

Globe.gl was the right fit for this project’s goal — an interactive, aesthetic globe experience with minimal boilerplate and a focused API. It got me building cool stuff faster.

Globe Implementation Details

Initial Setup

The basic Globe.gl initialization includes configuring camera settings, lighting, and textures. (This is where my actual initialization code would go.)

const globe = Globe()(document.getElementById('globeViz'))
  .globeImageUrl('https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg')
  .backgroundImageUrl('https://unpkg.com/three-globe/example/img/night-sky.png')
  .onGlobeReady(() => {
    globe.controls().target.set(0, 0, 0);            // Look at center of globe
    globe.camera().position.set(0, 0, 330);          // Pull camera back for overview
    globe.controls().update();                       // Apply changes
  });

This configuration disables auto-rotation (unlike most examples), providing a stable and intentional starting point for user-driven navigation.

Marker System

This section handles how yellow dots (for cities) and red pins (for attractions) are created, as well as their event handlers for interactivity.

function renderMarkers(dataSet) {
  globe.htmlElementsData(dataSet)
    .htmlLat(d => d.lat)
    .htmlLng(d => d.lng)
    .htmlElement(d => {
      const el = document.createElement('div');
      el.className = d.icon === 'circle' ? 'city-marker' : 'place-marker';
      el.dataset.tooltip = d.name;

      el.onclick = () => showGallery(d);

      el.onmouseenter = () => {
        tooltipTimeout = setTimeout(() => {
          const rect = el.getBoundingClientRect();
          tooltip.textContent = d.name;
          tooltip.style.left = `${rect.left + rect.width / 2}px`;
          tooltip.style.top = `${rect.top + 100}px`;
          tooltip.style.opacity = '1';
        }, 300);
      };

      el.onmouseleave = () => {
        clearTimeout(tooltipTimeout);
        tooltip.style.opacity = '0';
      };

      return el;
    });
}

// Zoom logic: switch between city dots and red pins
globe.controls().addEventListener('change', () => {
  const zoom = globe.camera().position.length();
  const threshold = 110;

  if (zoom < threshold && currentMode !== 'place') {
    renderMarkers(placeMarkers);
    currentMode = 'place';
  } else if (zoom >= threshold && currentMode !== 'city') {
    renderMarkers(cityMarkers);
    currentMode = 'city';
  }
});

This approach keeps the globe clean at first glance and reveals details only when the user intentionally zooms in — improving usability and performance while maintaining visual clarity.

Data Architecture

The hierarchical data structure supports nested locations, multiple photos per location, and metadata such as dates and descriptions.

[
  {
    "name": "Chicago",
    "lat": 41.8827,
    "lng": -87.6233,
    "city": true,
    "places": [
      {
        "name": "The Bean",
        "lat": 41.8826,
        "lng": -87.6233,
        "images": [
          {
            "url": "https://res.cloudinary.com/dudip2vsk/image/upload/v1747414654/IMG-20250516-WA0004_dbslyh.jpg",
            "date": "2024-11-30"
          },
          {
          "url": "https://res.cloudinary.com/.....", //put the real URL here
            "date": "2024-11-30"
          }
        ]
      },
      {
        "name": "Navy Pier",
        "lat": 41.8916,
        "lng": -87.6079,
        "images": [
          {
            "url": "https://res.cloudinary.com/......", //put the real URL here
            "date": "2024-11-30"
          }
        ],
        "icon": "marker",
        "albumId": "Navy_pier"
      }
    ]
  }
]

Building Scalable Photo Albums with Cloudinary

Creating the JSON files for each album manually would be a huge pain, especially with hundreds of photos! Instead of copying and pasting each link, I wrote a simple Node.js script to automate the process. This script connects to Cloudinary, fetches all the images within a specified folder, and then generates an optimized JSON file containing the thumbnail and full-size image URLs, along with the capture date.

Here's the code I used:

// data-dumper.js
const fs = require('fs');
const path = require('path');
const cloudinary = require('cloudinary').v2;

// Cloudinary credentials (PLACEHOLDER: Replace with your actual credentials)
cloudinary.config({
  cloud_name: 'YOUR_CLOUD_NAME',
  api_key: 'YOUR_API_KEY',
  api_secret: 'YOUR_API_SECRET'
});

// Folder path & JSON album name
const albumFolder = 'photo dump/Penn station'; // The folder on Cloudinary
const albumId = 'Penn_station'; // The desired ID for your album JSON file
const outputDir = path.join(__dirname, 'data', 'albums'); // Where the JSON file will be saved
const outputFile = path.join(outputDir, `${albumId}.json`);
const capturedDate = '2025-05-12'; // Date associated with this album

// Ensure output directory exists
fs.mkdirSync(outputDir, { recursive: true });

async function fetchAllImages() {
  let allResources = [];
  let nextCursor = undefined;

  do {
    const result = await cloudinary.search
      .expression(`folder:"${albumFolder}"`)
      .sort_by('public_id', 'asc')
      .max_results(500)      // Fetch up to 500 images per request
      .next_cursor(nextCursor)    // Use the cursor for pagination
      .execute();

    allResources.push(...result.resources);
    nextCursor = result.next_cursor;
    console.log(`Workspaceed ${allResources.length} / ~? images so far…`);
  } while (nextCursor); // Keep fetching until no more images

  return allResources;
}

(async () => {
  try {
    const resources = await fetchAllImages();
    console.log(`✅ Total images found: ${resources.length}`);

    // Map the Cloudinary resources to your desired JSON format
    const albumJson = resources.map(img => ({
      thumb: cloudinary.url(img.public_id, {
        width: 300,
        crop: 'fill',
        format: 'jpg',
        quality: 'auto'
      }),
      full: img.secure_url,
      captured: capturedDate
    }));

    // Write the generated JSON to a file
    fs.writeFileSync(outputFile, JSON.stringify(albumJson, null, 2));
    console.log(`🚀 Album JSON written to ${outputFile}`);
  } catch (err) {
    console.error('❌ Error fetching images:', err);
  }
})();

It's important to note that the data structure for individual album JSONs is distinct from the main location JSONs. While location data focuses on geographical points and their directly associated images, album JSONs are designed to manage collections of photos from a specific event or series.

To seamlessly integrate albums with specific locations, I've implemented a tagging system within the location JSON files. If a location includes an "albumId": "json_name" tag, the client-side application recognizes that a full album is available. This triggers the dynamic display of a "View Full Album" button for that location. Clicking the button fetches the corresponding album JSON and then loads the associated photos from Cloudinary on demand—without preloading them. This strategy keeps the initial page load fast while allowing users to explore full photo albums only when they choose to.

Bandwidth Optimization

Cloudinary Configuration

My Cloudinary configuration uses transformation presets for thumbnails, medium, and HD images. Below is a sample:

// Cloudinary URL Helpers
function getThumbUrl(url, width = 200) {
  return url ? url.replace('/upload/', `/upload/c_fill,w_${width},dpr_auto,f_auto,q_auto/`) : '';
}

function getFullUrl(url, maxWidth = 1200) {
  return url ? url.replace('/upload/', `/upload/c_limit,w_${maxWidth},dpr_auto,f_auto,q_auto/`) : '';
}

function getHDUrl(url) {
  // Removes transformation to get original HD image
  return url.replace(/\/upload\/[^/]+\//, '/upload/');
}

Loading Sequence Logic

This workflow shows initial thumbnail loading, upgrading image quality on click or hover, and caching the HD version.

function updatePhotoViewer() {
  const img = document.getElementById('viewer-img');
  const url = photoArray[currentPhotoIndex];
  const hdCookie = 'hd_loaded_' + btoa(url);
  const cachedHD = localStorage.getItem('hd_' + url);

  // Show cached HD if already loaded
  if (getCookie(hdCookie) === 'true' && cachedHD) {
    img.src = cachedHD;
    return;
  }

  // Load optimized version first
  img.onload = () => {
    setTimeout(() => {
      document.getElementById('load-hd-btn').style.display = 'block';
    }, 4000);
  };
  img.src = getFullUrl(url); // loads compressed version
}

function loadHDImage() {
  const url = photoArray[currentPhotoIndex];
  const rawHDUrl = url.replace(/\/upload\/[^/]+\//, '/upload/');
  const hdCookie = 'hd_loaded_' + btoa(rawHDUrl);
  const cacheKey = 'hd_' + rawHDUrl;

  fetch(rawHDUrl)
    .then(res => res.blob())
    .then(blob => {
      const reader = new FileReader();
      reader.onloadend = () => {
        const dataUrl = reader.result;
        localStorage.setItem(cacheKey, dataUrl);
        setCookie(hdCookie, 'true', 365);
        document.getElementById('viewer-img').src = dataUrl;
      };
      reader.readAsDataURL(blob);
    });
}

Initially, an optimized version of the image is loaded using Cloudinary transformations. After a few seconds, users are prompted to upgrade to HD. If HD is requested, it's fetched, cached in localStorage, and future visits skip reloading it by checking a persistent cookie.

Performance Metrics

Before and after optimization metrics:

Security Layer: Iron Gate System

Architecture Diagram

An overview of the client-side security layers:


┌──────────────────────────────┐
│      USER REQUEST ARRIVES    │
└────────────┬─────────────────┘
             ▼
   ➤ Refresh Counter via Cookie
   ➤ Warning Threshold Logic
   ➤ Temporary Ban if Abused
   ➤ Trap Button for Bots (Honeypot)
   ➤ Headless/Automation Detection
   ➤ FingerprintJS2 Browser Hash
   ➤ Countdown Delay (4 seconds)
             ▼
┌──────────────────────────────┐
│     VERIFIED → ALBUM PAGE    │
└──────────────────────────────┘
Key Techniques Used:

This early version of my IronGate™ system was designed to be lenient by default — I didn’t want any legitimate users getting blocked, and I didn’t want to delay progress by over-engineering this security layer in the first iteration.

  • Honeypot Trap
    An invisible button is placed on the page to catch bots that auto-click every element.
  • Refresh Spam Logger
    Tracks page refresh timestamps using cookies. More than 10 refreshes within 2 minutes triggers warnings. After 3 warnings, a 60-second temporary ban is issued automatically.
  • Fingerprinting
    Uses FingerprintJS2 to uniquely identify the user’s browser and set a verification cookie to grant access.
  • Countdown Timer
    Adds a short 4-second delay before allowing access to simulate a human-like wait, blocking impatient scripts and bots.
  • Lenient by Design
    At this early stage, I’m intentionally avoiding harsh restrictions or rate-limiting. Instead, the system favors gentle warnings and short bans to ensure a smooth experience for genuine users.

Implementation Details

// IronGate: Early-Stage Protection Layer (Lenient Mode)

(function IronGate() {
  const COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes window
  const MAX_REFRESHES = 10;
  const WARNING_THRESHOLD = 3;
  const BAN_DURATION_MS = 60 * 1000; // 60 seconds

  const getCookie = (name) => {
    const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
    return match ? decodeURIComponent(match[2]) : null;
  };

  const setCookie = (name, value, seconds) => {
    const expires = new Date(Date.now() + seconds * 1000).toUTCString();
    document.cookie = \`\${name}=\${encodeURIComponent(value)}; path=/; expires=\${expires}; SameSite=Strict\`;
  };

  const now = Date.now();

  // Check if user is temporarily banned
  const banUntil = parseInt(getCookie("IronGateBanUntil") || "0", 10);
  if (!isNaN(banUntil) && now < banUntil) {
    document.body.innerHTML = "

Access Temporarily Restricted

Please wait and try again later.

"; throw new Error("User is currently under a temporary ban."); } // Refresh abuse detection const refreshLogRaw = getCookie("IronGateRefreshLog"); const refreshLog = refreshLogRaw ? JSON.parse(atob(refreshLogRaw)) : []; const recentLog = refreshLog.filter(timestamp => now - timestamp < COOLDOWN_MS); recentLog.push(now); setCookie("IronGateRefreshLog", btoa(JSON.stringify(recentLog)), 120); // Expires in 2 minutes if (recentLog.length > MAX_REFRESHES) { let warnings = parseInt(getCookie("IronGateWarnings") || "0", 10); warnings++; setCookie("IronGateWarnings", warnings, 1800); // Store for 30 minutes if (warnings >= WARNING_THRESHOLD) { setCookie("IronGateBanUntil", now + BAN_DURATION_MS, 60); document.body.innerHTML = "

Too Many Requests

Your access is temporarily blocked due to unusual activity.

"; throw new Error("Temporary ban triggered."); } else { document.body.innerHTML = \`

Warning \${warnings}

Please reduce refresh frequency to avoid temporary suspension.

\`; throw new Error("Warning threshold not yet exceeded."); } } // Bot behavior trap (honeypot) window.trapTriggered = function () { document.body.innerHTML = "

Access Denied

Suspicious automated interaction detected.

"; throw new Error("Honeypot triggered."); }; // Fingerprint + verification cookie window.verifyAndRedirect = function () { Fingerprint2.get(components => { const values = components.map(c => c.value); const hash = Fingerprint2.x64hash128(values.join(""), 31); const timestamp = Date.now(); const encodedTime = btoa(timestamp.toString()).split("").reverse().join(""); document.cookie = \`IronGateVerification=1_\${encodedTime}_\${hash}; path=/; max-age=86400; SameSite=Strict\`; localStorage.setItem("IronGateCooldown", timestamp); window.location.href = "album.html"; }); }; // Countdown timer for natural delay let seconds = 4; const countdownEl = document.getElementById("countdown"); countdownEl.textContent = seconds; const timer = setInterval(() => { seconds--; countdownEl.textContent = seconds; if (seconds <= 0) { clearInterval(timer); verifyAndRedirect(); } }, 1000); })();

This early-stage implementation of the IronGate system applies client-side verification to mitigate spam, bots, and refresh abuse. It uses cookies to log refreshes, fingerprinting for soft identification, and a short countdown delay. The current setup is intentionally lenient to avoid blocking real users during the initial rollout phase.

Debugging Challenges

Camera Control Issues

Discusses zoom level adjustments and smooth transitions with before/after examples.

// Before (jittery behavior, auto zoom from library defaults)
const globe = Globe()(document.getElementById('globeViz'));

// After (manual camera smoothing on load)
const globe = Globe()(document.getElementById('globeViz'))
  .globeImageUrl('https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg')
  .backgroundImageUrl('https://unpkg.com/three-globe/example/img/night-sky.png')
  .onGlobeReady(() => {
    globe.controls().target.set(0, 0, 0);      // stabilize center
    globe.camera().position.set(0, 0, 330);    // fixed zoom distance
    globe.controls().update();                 // apply changes
  });

This small tweak greatly improved user experience by making zoom levels predictable and consistent, which was essential for triggering pin switches between cities and locations.

Memory Leaks

While testing the image viewer, I noticed increased memory usage after navigating through multiple photos quickly. The issue was tied to setTimeout and onload handlers that weren’t always cleared properly, especially when users clicked rapidly. To prevent leaked timers and avoid unnecessary DOM buildup, I cleaned up event listeners and timeouts in both the HD loading and viewer update logic.

// Before (risk of orphaned timeouts if user navigates away quickly)
img.onload = () => {
  setTimeout(() => {
    document.getElementById('load-hd-btn').style.display = 'block';
  }, 4000);
};

// After (clears any existing timeout before setting a new one)
clearTimeout(showHDButtonTimeout);
img.onload = () => {
  showHDButtonTimeout = setTimeout(() => {
    const btn = document.getElementById('load-hd-btn');
    if (btn) btn.style.display = 'block';
  }, 4000);
};

Additionally, onload fallbacks were added to force cleanup in edge cases where events never fired:

// Fallback cleanup if image doesn't trigger onload (rare, but possible)
setTimeout(() => {
  if (!rendered) {
    cleanupLoadingUI();
    handleHDLoaded(rawUrl);
  }
}, 5000);

By aggressively clearing timeouts and safely checking element presence, I ensured the viewer remains lightweight and leak-free over long usage sessions.

Future Improvements

Planned Enhancements

1. [ ] IndexedDB for scalable offline cache 
2. [ ] Improved UX on the 3D globe (smooth transitions, tooltips) 
3. [ ] Guided demos showing how to explore the globe 
4. [ ] Captions and descriptions for all uploaded photos 
5. [ ] Just... building cool stuff

Experimental Features

Ideas currently under exploration:

1. [ ] Making the globe texture more HD and sharp 
2. [ ] Brainstorming unique visual effects tied to zoom levels 
3. [ ] Redundant photo hosting with automatic fallback if one server fails or hits bandwidth cap 
4. [ ] Traffic-handling algorithms to manage surges or abuse gracefully 

Conclusion

This project has already taught me a great deal — from progressive enhancement to smart resource management and striking a balance between performance and visual richness. As the build evolves, I’m continuing to learn and iterate. The full source code will be published on my GitHub once the project reaches a stable release.

Now, as promised, I’m off to enjoy some well-earned spaghetti in Paris — special thanks to my friend Raphaël for making it happen. If you have questions or ideas, feel free to reach out at dev-aryan@aboutsharma.com or aryan@aboutsharma.com.