World Monitor Map Engine: 45 Data Layers for Global Intelligence Visualization
When I started building World Monitor, I had one problem: how do you display 45 different data layers—from military bases to undersea cables to live earthquake feeds—on a single map interface without melting the user’s browser?
I tried the obvious approach first. Throw everything on a Google Map with markers. That worked until I hit about 200 markers. Then the frame rate dropped to a slideshow. Users on mobile devices gave up entirely.
The real challenge wasn’t just rendering thousands of points. It was doing it across two different map paradigms—flat 2D analysis and immersive 3D globe exploration—while keeping the bundle under 250KB gzipped.
Here’s how I ended up with a dual-engine architecture using deck.gl for flat maps and globe.gl for 3D visualization, and what I learned along the way.
The Two-Engine Problem
I didn’t start with two map engines. That would be insane, right? I started with deck.gl because I needed WebGL performance for thousands of markers.
But then users asked for a globe view. Not just a cosmetic 3D effect—a functional globe where they could see data distribution across hemispheres. Adding a second map engine meant:
- Two rendering pipelines
- Two clustering implementations
- Two ways of handling layer visibility
- Synchronized state between both views
The key insight was treating them as two views of the same data, not two separate systems.
Data Layer Architecture
I organized the 45 data layers into categories. This wasn’t just for UI organization—it determined how I loaded and filtered data.
// Layer categories and their rendering requirementsconst layerCategories = { geopolitical: ['conflicts', 'hotspots', 'sanctions', 'protests'], military: ['bases', 'nuclear', 'gamma', 'apt', 'spaceports', 'minerals'], infrastructure: ['cables', 'pipelines', 'outages', 'datacenters'], transport: ['ships', 'delays'], natural: ['natural', 'weather'], overlays: ['daynight', 'countries', 'waterways', 'routes', 'fires']};Each category has different data characteristics. Military bases are static—maybe 226 total points. Ships via AIS are dynamic, updating every few minutes with thousands of vessels. Earthquakes come in bursts.
I needed different strategies for each.
Static Layers (Military Bases, Nuclear Facilities)
These are loaded once and cached. The data changes rarely, so I ship GeoJSON files with the bundle.
// Static layer loadingasync function loadStaticLayer(layerId) { const response = await fetch(`/data/${layerId}.geojson`); return response.json();}Dynamic Layers (Ships, Natural Events)
These poll APIs on intervals. I use Web Workers to process the data off the main thread.
// Dynamic layer with pollingclass DynamicLayer { constructor(source, interval = 60000) { this.source = source; this.interval = interval; this.data = []; }
async start() { await this.fetch(); setInterval(() => this.fetch(), this.interval); }
async fetch() { const worker = new Worker('/workers/data-processor.js'); worker.postMessage({ source: this.source }); worker.onmessage = (e) => { this.data = e.data; this.render(); }; }}The Web Worker approach was critical. Processing 10,000 AIS positions on the main thread would freeze the UI. Moving it to a worker keeps the map responsive.
Clustering: The Performance Saviour
Clustering wasn’t optional—it was required for any layer with more than 100 points. I tried three approaches before settling on Supercluster.
Attempt 1: Built-in MapLibre Clustering
// Works but limited controlmap.addSource('bases', { type: 'geojson', data: basesGeoJSON, cluster: true, clusterRadius: 50});This worked for simple cases but didn’t give me control over cluster expansion or custom popup content.
Attempt 2: Custom Distance-Based Clustering
I wrote a simple distance-based cluster algorithm. It worked for small datasets but was O(n2) complexity. With 5000 points, clustering took 2 seconds. Not acceptable.
Attempt 3: Supercluster
import Supercluster from 'supercluster';
const cluster = new Supercluster({ radius: 40, maxZoom: 16});
cluster.load(points);
// Get clusters at current viewconst clusters = cluster.getClusters(bounds, zoom);Supercluster uses a spatial index (k-d tree) for O(n log n) clustering. Even with 10,000 points, clustering happens in milliseconds.
I added category-specific clustering logic:
// Grouping logic varies by data typeconst groupingRules = { protests: (p) => p.country, // Cluster by country techHQs: (h) => h.city, // Cluster by city techEvents: (e) => e.location // Cluster by exact location};
function shouldCluster(a, b, layerId) { const rule = groupingRules[layerId]; if (!rule) return distance(a, b) < clusterRadius; return rule(a) === rule(b) && distance(a, b) < clusterRadius;}This ensures protests in the same country cluster together, but protests in different countries stay separate—making the political context clearer.
The Flat Map: deck.gl + MapLibre
For the flat map, I combine deck.gl layers with a MapLibre GL base map. The integration took some trial and error.
Layer Types I Use
| Layer Type | Use Case | Performance |
|---|---|---|
| ScatterplotLayer | Point data (bases, events) | Excellent |
| GeoJsonLayer | Polygons (countries, regions) | Good |
| PathLayer | Lines (cables, pipelines, routes) | Excellent |
| IconLayer | Custom markers | Moderate |
| ArcLayer | Curved connections | Good |
| HeatmapLayer | Density visualization | Moderate |
| H3HexagonLayer | Hexagonal aggregation | Excellent |
The PMTiles Breakthrough
I didn’t want to depend on external tile servers. That’s a single point of failure and a privacy concern for intelligence data.
PMTiles lets me host my own basemap tiles as a single file served from the same origin as the app.
import { PMTiles } from 'pmtiles';
const pmtiles = new PMTiles('/tiles/world.pmtiles');
const map = new maplibregl.Map({ style: { version: 8, sources: { 'basemap': { type: 'vector', url: 'pmtiles:///tiles/world.pmtiles' } }, layers: [/* ... */] }});This eliminated my tile server dependency and improved load times by 40%.
The 3D Globe: globe.gl
The globe view serves a different purpose. It’s for understanding global distribution patterns, not detailed analysis.
globe.gl makes this relatively straightforward:
import Globe from 'globe.gl';
const globe = Globe() .globeImageUrl('//unpkg.com/three-globe/example/img/earth-blue-marble.jpg') .bumpImageUrl('//unpkg.com/three-globe/example/img/earth-topology.png') .backgroundImageUrl('//unpkg.com/three-globe/example/img/night-sky.png') .htmlElementsData(mergedData) .htmlLat(d => d.lat) .htmlLng(d => d.lng) .htmlElement(d => createMarker(d));
// Auto-rotate after idlelet idleTimer;globe.onIdle(() => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { globe.autoRotate(true); }, 10000);});Merging Data for the Globe
The globe needs a single merged dataset with a discriminator field:
// Merge all layer data into single arrayfunction mergeForGlobe(layerData) { return Object.entries(layerData) .flatMap(([kind, data]) => data.map(item => ({ ...item, _kind: kind })) );}The _kind field lets me style markers differently based on their source layer.
State Synchronization
Both maps share the same state object. When you toggle a layer, filter by time, or zoom to a region, both views update.
// Shared stateconst mapState = { lat: 20, lon: 0, zoom: 2, time: '24h', view: 'global', layers: ['conflicts', 'hotspots', 'bases']};
// URL sync for shareable linksfunction syncToUrl() { const params = new URLSearchParams({ lat: mapState.lat, lon: mapState.lon, zoom: mapState.zoom, time: mapState.time, view: mapState.view, layers: mapState.layers.join(',') }); history.replaceState(null, '', `?${params}`);}
// Example: ?lat=38.9&lon=-77&zoom=6&layers=bases,conflicts,hotspotsThis makes every view shareable. You can send a colleague a link showing exactly what you’re looking at.
Mobile Optimization
Mobile was the hardest part. WebGL is power-hungry, and I couldn’t justify killing users’ batteries for eye candy.
Strategy 1: Different Rendering Backend
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);const hasWebGL = (() => { try { return !!document.createElement('canvas').getContext('webgl'); } catch { return false; }})();
// Mobile + no WebGL: D3.js SVG rendering// Mobile + WebGL: deck.gl with reduced layers// Desktop: Full deck.gl + globe.glFor mobile without WebGL, I fall back to D3.js SVG rendering:
import * as d3 from 'd3';
const svg = d3.select('#map') .append('svg') .attr('width', width) .attr('height', height);
// D3 projection for mobileconst projection = d3.geoMercator() .scale(scale) .center([lon, lat]);
// SVG is slower but works everywheresvg.selectAll('circle') .data(points) .enter() .append('circle') .attr('cx', d => projection([d.lon, d.lat])[0]) .attr('cy', d => projection([d.lon, d.lat])[1]) .attr('r', 4);Strategy 2: Reduced Layer Set
On mobile, I only enable essential layers by default:
const mobileDefaultLayers = [ 'conflicts', // Active conflict zones 'hotspots', // Intelligence hotspots 'sanctions', // Countries under sanctions 'outages', // Network disruptions 'natural', // Earthquakes, storms 'weather' // Severe weather];
// Disabled on mobile by default:// bases, nuclear, cables, pipelines, datacenters, ships, flights, protestsThis reduces the initial data load from ~2MB to ~300KB on mobile.
Time Filtering
The time filter (1h, 6h, 24h, 48h, 7d) was tricky because different data sources have different timestamp formats.
function filterByTime(data, timeRange) { const now = Date.now(); const thresholds = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '48h': 172800000, '7d': 604800000 };
const cutoff = now - thresholds[timeRange];
return data.filter(item => { // Handle different timestamp formats const ts = item.timestamp || item.created_at || item.date; return new Date(ts).getTime() > cutoff; });}Some sources (USGS earthquakes) provide precise timestamps. Others (ACLED protests) have daily granularity. The filter is forgiving—if a source doesn’t support the selected time range, it shows all available data with a warning.
Layer Configuration
I keep layer definitions in a single configuration file:
export const layerDefinitions = { bases: { name: 'Military Bases', category: 'military', renderer: ['flat', 'globe'], premium: false, defaultVisible: true, mobileDefault: false, icon: 'military-base', color: '#ff6b6b', description: '226 global installations from 9 operators' }, ships: { name: 'Vessel Tracking', category: 'transport', renderer: ['flat'], premium: true, defaultVisible: false, mobileDefault: false, icon: 'ship', color: '#4ecdc4', description: 'Live AIS data with chokepoint monitoring' }, // ... 43 more layers};This centralizes all layer metadata. When I add a new layer, I add one entry here and the UI, filtering, and rendering all update automatically.
Performance Results
After implementing all these optimizations:
| Metric | Before | After |
|---|---|---|
| Bundle size | 1.2MB | 250KB |
| Initial load | 4.5s | 1.2s |
| Time to interactive | 6s | 2s |
| Frame rate (10k markers) | 8fps | 60fps |
| Mobile battery drain | 15%/hr | 3%/hr |
The key wins were:
- Supercluster for O(n log n) clustering
- Web Workers for off-main-thread processing
- PMTiles for self-hosted basemaps
- SVG fallback for mobile devices
- Lazy loading of non-essential layers
Lessons Learned
Start with clustering. I wasted weeks trying to optimize raw marker rendering before accepting that clustering is the only solution for large datasets.
Two engines aren’t twice the work if you share state. The deck.gl and globe.gl views share the same data structures and state management. The rendering is different, but the logic is shared.
Mobile is a different product. I tried to make the mobile experience a subset of desktop. It should be a different UX entirely. The SVG fallback changed everything for mobile users.
URL state is underrated. Making every map state shareable via URL transformed how users collaborate. It’s now the primary way analysts share findings with each other.
Self-host everything you can. External tile servers, API dependencies, CDN failures—these all caused outages in early versions. PMTiles and bundled data eliminated most external dependencies.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments