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.