Skip to content

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 requirements
const 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 loading
async 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 polling
class 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 control
map.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 view
const 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 type
const 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 TypeUse CasePerformance
ScatterplotLayerPoint data (bases, events)Excellent
GeoJsonLayerPolygons (countries, regions)Good
PathLayerLines (cables, pipelines, routes)Excellent
IconLayerCustom markersModerate
ArcLayerCurved connectionsGood
HeatmapLayerDensity visualizationModerate
H3HexagonLayerHexagonal aggregationExcellent

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 idle
let 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 array
function 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 state
const mapState = {
lat: 20,
lon: 0,
zoom: 2,
time: '24h',
view: 'global',
layers: ['conflicts', 'hotspots', 'bases']
};
// URL sync for shareable links
function 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,hotspots

This 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.gl

For 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 mobile
const projection = d3.geoMercator()
.scale(scale)
.center([lon, lat]);
// SVG is slower but works everywhere
svg.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, protests

This 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:

src/config/map-layer-definitions.ts
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:

MetricBeforeAfter
Bundle size1.2MB250KB
Initial load4.5s1.2s
Time to interactive6s2s
Frame rate (10k markers)8fps60fps
Mobile battery drain15%/hr3%/hr

The key wins were:

  1. Supercluster for O(n log n) clustering
  2. Web Workers for off-main-thread processing
  3. PMTiles for self-hosted basemaps
  4. SVG fallback for mobile devices
  5. 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