File size: 7,091 Bytes
d705bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# Plan: Map Performance Improvements (Revised v2)

## Context

worldmonitor.app renders a real-time geopolitical map using three engines:

- **DeckGLMap** (WebGL, deck.gl over MapLibre) β€” primary desktop map
- **GlobeMap** (globe.gl / Three.js) β€” 3D globe mode
- **Map.ts** (Leaflet + D3 SVG) β€” mobile fallback

Default web layout enables 12 map layers. Performance profiling (built-in console.warn at >16ms) revealed `buildLayers()` is a hot path.

## Diagnosis

Three real bottlenecks identified:

1. **Supercluster indexes built unconditionally at startup** β€” `rebuildTechHQSupercluster()` and `rebuildDatacenterSupercluster()` run on map `'load'` and basemap switch for ALL users, even when `SITE_VARIANT !== 'tech'` and datacenters are off.

2. **`filterByTime()` re-runs 10Γ— on every `buildLayers()` call** β€” called on every data update, zoom change, and layer toggle. `filterByTime()` uses `Date.now()` as its cutoff, so results expire over time β€” but rebuilding them on every render is wasteful when data hasn't changed within the current minute.

3. **`initStaticLayers()` in GlobeMap processes all 9 static datasets unconditionally** β€” MILITARY_BASES, NUCLEAR_FACILITIES, GAMMA_IRRADIATORS, SPACEPORTS, ECONOMIC markers, AI_DATA_CENTERS, WATERWAYS, MINERALS, UNDERSEA_CABLES/PIPELINES all processed at startup for every variant.

### Ruled out (no issue):

- `rafSchedule` is NOT a continuous loop β€” fires only when called, already correct
- `flushMarkersImmediate()` already gates by `this.layers.xxx` β€” no redundant pushes
- GlobeMap debounce (100ms + 300ms max) already coalesces rapid updates

---

## Change 1: Lazy Supercluster Initialization

**File**: `src/components/DeckGLMap.ts`

**Remove** the unconditional eager builds at `'load'` and basemap-switch:
```typescript
// REMOVE these two lines from 'load' handler (and basemap-switch handler):
this.rebuildTechHQSupercluster();
this.rebuildDatacenterSupercluster();
```

**Add** lazy-init inside `updateClusterData()`, before the existing cluster usage. `updateClusterData()` already computes the exact conditions needed:

```typescript
// In updateClusterData(), after computing useTechHQ / useDatacenterClusters:
const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs;
const useDatacenterClusters = layers.datacenters && zoom < 5;

if (useTechHQ && !this.techHQSC) this.rebuildTechHQSupercluster();
if (useDatacenterClusters && !this.datacenterSC) this.rebuildDatacenterSupercluster();
```

First time these layers are active at the right zoom, the cluster is built once and cached.

**Risk**: Very low. `updateClusterData()` already has early-return guards and is called at every render. The lazy-init path is a one-time cost, identical to the current eager cost β€” just deferred.

---

## Change 2: Memoized `filterByTime` Helper

**File**: `src/components/DeckGLMap.ts`

Add a single memoized wrapper using `WeakMap` (avoids strong-ref memory leak on old array replacements):

```typescript
private _timeFilterCache = new WeakMap<object, { min: number; range: TimeRange; result: unknown[] }>();

private filterByTimeCached<T>(items: T[], key: (t: T) => Date | string | number): T[] {
  const min = Math.floor(Date.now() / 60000); // 1-minute bucket
  const range = this.state.timeRange;
  const cached = this._timeFilterCache.get(items as object);
  if (cached && cached.min === min && cached.range === range) return cached.result as T[];
  const result = this.filterByTime(items, key);
  this._timeFilterCache.set(items as object, { min, range, result });
  return result;
}
```

Cache invalidation:

- **New data**: `set*()` methods assign a new array reference β†’ WeakMap miss, old entry GC'd
- **Time range change**: `range` differs β†’ cache miss
- **Clock advance**: `min` bucket (per-minute) expires naturally β†’ recompute picks up newly-expired events

In `buildLayers()`, replace all 10 inline calls (change `filterByTime` β†’ `filterByTimeCached`). No setter or state changes needed.

**Risk**: Low. WeakMap eliminates memory leak. 1-minute bucket means at most 60s of stale filtering β€” acceptable given AIS data refreshes every 20s and triggers a new array ref.

---

## Change 3: Guard `initStaticLayers()` in GlobeMap

**File**: `src/components/GlobeMap.ts`

Wrap all 9 datasets in `initStaticLayers()` with their layer-state guards. Add `ensureStaticDataForLayer(layer)` called from both `setLayers()` (newly-enabled keys) and `enableLayer()` (programmatic enables β€” URL restore, search, panel actions):

```typescript
private ensureStaticDataForLayer(layer: keyof MapLayers): void {
  switch (layer) {
    case 'bases':       if (!this.milBaseMarkers.length) this.milBaseMarkers = MILITARY_BASES.map(...); break;
    case 'nuclear':     if (!this.nuclearSiteMarkers.length) this.nuclearSiteMarkers = NUCLEAR_FACILITIES.filter(...).map(...); break;
    case 'irradiators': if (!this.irradiatorSiteMarkers.length) this.irradiatorSiteMarkers = GAMMA_IRRADIATORS.map(...); break;
    case 'spaceports':  if (!this.spaceportSiteMarkers.length) this.spaceportSiteMarkers = SPACEPORTS.filter(...).map(...); break;
    case 'economic':    if (!this.economicMarkers.length) this.economicMarkers = ECONOMIC_CENTERS.map(...); break;
    case 'datacenters': if (!this.datacenterMarkers.length) this.datacenterMarkers = AI_DATA_CENTERS.filter(...).map(...); break;
    case 'waterways':   if (!this.waterwayMarkers.length) this.waterwayMarkers = STRATEGIC_WATERWAYS.map(...); break;
    case 'minerals':    if (!this.mineralMarkers.length) this.mineralMarkers = CRITICAL_MINERALS.filter(...).map(...); break;
    case 'tradeRoutes': if (!this.tradeRouteSegments.length) this.tradeRouteSegments = resolveTradeRouteSegments(); break;
    case 'cables':
    case 'pipelines':   if (!this.globePaths.length) this.globePaths = [...CABLES.map(...), ...PIPELINES.map(...)]; break;
  }
}
```

Hook into `setLayers()`:
```typescript
for (const k of Object.keys(layers) as (keyof MapLayers)[]) {
  if (!prev[k] && layers[k]) this.ensureStaticDataForLayer(k); // newly enabled
  // existing channel flush logic continues...
}
```

Hook into `enableLayer()`:
```typescript
public enableLayer(layer: keyof MapLayers): void {
  if (layer === 'dayNight') return;
  if (this.layers[layer]) return;
  (this.layers as any)[layer] = true;
  this.ensureStaticDataForLayer(layer); // lazy init for programmatic enables
  this.flushLayerChannels(layer);
  this.enforceLayerLimit();
}
```

**Risk**: Low. Static datasets never change at runtime. For variants where layers are on by default, `initStaticLayers()` runs at startup as before.

---

## Files Changed

| File | Change |
|---|---|
| `src/components/DeckGLMap.ts` | Changes 1 + 2 (~30 lines net) |
| `src/components/GlobeMap.ts` | Change 3 (~80 lines net) |

## Expected Improvements

- **Startup**: Superclusters not built until first needed; GlobeMap static data skipped for non-default layers (~40-200ms per variant)
- **Per-render**: `buildLayers()` hits WeakMap cache instead of re-filtering; at most 1 recompute per minute per active layer