Skip to content

Commit 3243f7f

Browse files
committed
perf(map): raise tile concurrency and cancel superseded vector tile loads
Raise OL maxTilesLoading from the default 16 to 32 so zoom-out tile bursts keep the browser's HTTP/1.1 connection pool fed. Add an abortable VectorTile tileLoadFunction that cancels in-flight loads from superseded zoom levels, freeing the connection pool for the now-visible level. Raster-source cancellation is intentionally left out: OL deprecates tileLoadFunction on raster (UrlTile) sources in favor of an ImageTile loader, which is a larger rework for a follow-up.
1 parent 281a61d commit 3243f7f

4 files changed

Lines changed: 338 additions & 3 deletions

File tree

src/app/modules/map/ol/lib/charts/layer-vector-chart.component.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { MapComponent } from '../map.component';
1818
import { FBChart } from 'src/app/types';
1919
import { initPMTilesVectorLayer } from './pmtiles-utils';
2020
import { extentFromBounds, resolveLayerMaxZoom } from './chart-utils';
21+
import { createAbortableVectorTileLoader } from './tile-loader-abort';
2122

2223
// ** Freeboard Vector TileLayer Chart **
2324
@Component({
@@ -33,6 +34,7 @@ export class VectorChartLayerComponent implements OnDestroy {
3334
protected mapMaxZoom = input<number>();
3435

3536
private layer: VectorTileLayer;
37+
private abortPendingTileLoads?: () => void;
3638
private changeDetectorRef = inject(ChangeDetectorRef);
3739
private mapComponent = inject(MapComponent);
3840

@@ -48,6 +50,8 @@ export class VectorChartLayerComponent implements OnDestroy {
4850
}
4951

5052
ngOnDestroy() {
53+
this.abortPendingTileLoads?.();
54+
this.abortPendingTileLoads = undefined;
5155
const map = this.mapComponent.getMap();
5256
if (this.layer) {
5357
map.removeLayer(this.layer);
@@ -76,6 +80,8 @@ export class VectorChartLayerComponent implements OnDestroy {
7680
if (chart[1].url.indexOf('.pmtiles') !== -1) {
7781
this.layer = initPMTilesVectorLayer(chart[1], this.zIndex());
7882
} else {
83+
const abortableLoader = createAbortableVectorTileLoader();
84+
this.abortPendingTileLoads = abortableLoader.abortPending;
7985
this.layer = new VectorTileLayer({
8086
source: new VectorTileSource({
8187
url: chart[1].url,
@@ -85,7 +91,8 @@ export class VectorChartLayerComponent implements OnDestroy {
8591
? chart[1].layers
8692
: null
8793
}),
88-
maxZoom: maxZ
94+
maxZoom: maxZ,
95+
tileLoadFunction: abortableLoader.tileLoadFunction
8996
}),
9097
preload: 0,
9198
zIndex: this.zIndex(),
@@ -100,7 +107,7 @@ export class VectorChartLayerComponent implements OnDestroy {
100107
this.layer.setMaxZoom(layerMaxZ);
101108
this.layer.setExtent(extentFromBounds(chart[1].bounds));
102109
if (chart[1].style) {
103-
applyStyle(this.layer as any, chart[1].style);
110+
applyStyle(this.layer, chart[1].style);
104111
}
105112
this.layer.set('id', chart[0]);
106113
this.layer.set('chartId', chart[0]);
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import type { FeatureLike } from 'ol/Feature';
2+
import type { Extent } from 'ol/extent';
3+
import type Projection from 'ol/proj/Projection';
4+
import type VectorTile from 'ol/VectorTile';
5+
import TileState from 'ol/TileState';
6+
import { afterEach, describe, expect, it, vi } from 'vitest';
7+
import {
8+
abortPendingControllers,
9+
bucketForZoom,
10+
createAbortableVectorTileLoader,
11+
releaseController,
12+
type PendingByZoom
13+
} from './tile-loader-abort';
14+
15+
type TileLoader = (
16+
extent: Extent,
17+
resolution: number,
18+
projection: Projection
19+
) => void;
20+
21+
function makeTile(z = 9) {
22+
let loader: TileLoader | undefined;
23+
const states: number[] = [];
24+
const tile = {
25+
getTileCoord: () => [z, 0, 0],
26+
setLoader: (nextLoader: TileLoader) => {
27+
loader = nextLoader;
28+
},
29+
setState: (state: number) => states.push(state)
30+
} as unknown as VectorTile<FeatureLike>;
31+
32+
return {
33+
tile,
34+
states,
35+
start: () => loader?.([] as unknown as Extent, 1, {} as Projection)
36+
};
37+
}
38+
39+
afterEach(() => {
40+
vi.unstubAllGlobals();
41+
});
42+
43+
function makePending(): PendingByZoom {
44+
return new Map();
45+
}
46+
47+
describe('bucketForZoom', () => {
48+
it('returns the same bucket on repeated calls at the same zoom', () => {
49+
const pending = makePending();
50+
const bucket1 = bucketForZoom(pending, 10);
51+
const bucket2 = bucketForZoom(pending, 10);
52+
expect(bucket1).toBe(bucket2);
53+
});
54+
55+
it('does not abort any controller when the zoom is unchanged', () => {
56+
const pending = makePending();
57+
const bucket = bucketForZoom(pending, 10);
58+
const controller = new AbortController();
59+
bucket.add(controller);
60+
61+
bucketForZoom(pending, 10);
62+
63+
expect(controller.signal.aborted).toBe(false);
64+
});
65+
66+
it('aborts all controllers from a superseded zoom', () => {
67+
const pending = makePending();
68+
const bucket = bucketForZoom(pending, 8);
69+
const c1 = new AbortController();
70+
const c2 = new AbortController();
71+
bucket.add(c1);
72+
bucket.add(c2);
73+
74+
bucketForZoom(pending, 12);
75+
76+
expect(c1.signal.aborted).toBe(true);
77+
expect(c2.signal.aborted).toBe(true);
78+
});
79+
80+
it('removes the old zoom bucket after superseding', () => {
81+
const pending = makePending();
82+
const oldBucket = bucketForZoom(pending, 8);
83+
const c = new AbortController();
84+
oldBucket.add(c);
85+
86+
bucketForZoom(pending, 12);
87+
88+
expect(pending.has(8)).toBe(false);
89+
expect(pending.has(12)).toBe(true);
90+
});
91+
92+
it('aborts controllers across multiple superseded zooms in one call', () => {
93+
const pending = makePending();
94+
const c5 = new AbortController();
95+
const c7 = new AbortController();
96+
pending.set(5, new Set([c5]));
97+
pending.set(7, new Set([c7]));
98+
99+
bucketForZoom(pending, 10);
100+
101+
expect(c5.signal.aborted).toBe(true);
102+
expect(c7.signal.aborted).toBe(true);
103+
expect(pending.has(5)).toBe(false);
104+
expect(pending.has(7)).toBe(false);
105+
expect(pending.has(10)).toBe(true);
106+
});
107+
});
108+
109+
describe('releaseController', () => {
110+
it('deletes the bucket when its last controller is removed', () => {
111+
const pending = makePending();
112+
const bucket = bucketForZoom(pending, 9);
113+
const controller = new AbortController();
114+
bucket.add(controller);
115+
116+
releaseController(pending, 9, controller);
117+
118+
expect(pending.has(9)).toBe(false);
119+
});
120+
121+
it('leaves the bucket intact when other controllers remain', () => {
122+
const pending = makePending();
123+
const bucket = bucketForZoom(pending, 9);
124+
const c1 = new AbortController();
125+
const c2 = new AbortController();
126+
bucket.add(c1);
127+
bucket.add(c2);
128+
129+
releaseController(pending, 9, c1);
130+
131+
expect(pending.has(9)).toBe(true);
132+
expect(pending.get(9)?.has(c2)).toBe(true);
133+
expect(pending.get(9)?.has(c1)).toBe(false);
134+
});
135+
136+
it('is a no-op when the zoom bucket has already been removed', () => {
137+
const pending = makePending();
138+
const controller = new AbortController();
139+
140+
expect(() => releaseController(pending, 9, controller)).not.toThrow();
141+
expect(pending.has(9)).toBe(false);
142+
});
143+
144+
it('is a no-op when the controller is not in the bucket', () => {
145+
const pending = makePending();
146+
const bucket = bucketForZoom(pending, 9);
147+
const resident = new AbortController();
148+
bucket.add(resident);
149+
const stranger = new AbortController();
150+
151+
expect(() => releaseController(pending, 9, stranger)).not.toThrow();
152+
expect(pending.has(9)).toBe(true);
153+
expect(pending.get(9)?.has(resident)).toBe(true);
154+
});
155+
});
156+
157+
describe('abortPendingControllers', () => {
158+
it('aborts every pending controller and clears the bookkeeping', () => {
159+
const c8 = new AbortController();
160+
const c9 = new AbortController();
161+
const pending: PendingByZoom = new Map([
162+
[8, new Set([c8])],
163+
[9, new Set([c9])]
164+
]);
165+
166+
abortPendingControllers(pending);
167+
168+
expect(c8.signal.aborted).toBe(true);
169+
expect(c9.signal.aborted).toBe(true);
170+
expect(pending.size).toBe(0);
171+
});
172+
});
173+
174+
describe('createAbortableVectorTileLoader', () => {
175+
it('marks an aborted tile as an error during teardown', async () => {
176+
vi.stubGlobal(
177+
'fetch',
178+
vi.fn((_src: string, init?: RequestInit) => {
179+
return new Promise<Response>((_resolve, reject) => {
180+
init?.signal?.addEventListener('abort', () => {
181+
reject(new DOMException('Aborted', 'AbortError'));
182+
});
183+
});
184+
})
185+
);
186+
const { tileLoadFunction, abortPending } =
187+
createAbortableVectorTileLoader();
188+
const tile = makeTile();
189+
190+
tileLoadFunction(tile.tile, 'https://example.test/tile.pbf');
191+
tile.start();
192+
expect(tile.states).toEqual([TileState.LOADING]);
193+
194+
abortPending();
195+
196+
await vi.waitFor(() => {
197+
expect(tile.states).toEqual([TileState.LOADING, TileState.ERROR]);
198+
});
199+
});
200+
201+
it('marks ordinary fetch failures as errors', async () => {
202+
vi.stubGlobal(
203+
'fetch',
204+
vi.fn(() => Promise.reject(new Error('offline')))
205+
);
206+
const { tileLoadFunction } = createAbortableVectorTileLoader();
207+
const tile = makeTile();
208+
209+
tileLoadFunction(tile.tile, 'https://example.test/tile.pbf');
210+
tile.start();
211+
212+
await vi.waitFor(() => {
213+
expect(tile.states).toEqual([TileState.LOADING, TileState.ERROR]);
214+
});
215+
});
216+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import TileState from 'ol/TileState';
2+
import type VectorTile from 'ol/VectorTile';
3+
import type { FeatureLike } from 'ol/Feature';
4+
import type { Extent } from 'ol/extent';
5+
import type Projection from 'ol/proj/Projection';
6+
7+
type PendingByZoom = Map<number, Set<AbortController>>;
8+
9+
export type VectorTileLoadFunction = (
10+
tile: VectorTile<FeatureLike>,
11+
src: string
12+
) => void;
13+
14+
export interface AbortableVectorTileLoader {
15+
tileLoadFunction: VectorTileLoadFunction;
16+
abortPending: () => void;
17+
}
18+
19+
/**
20+
* Abort and drop controllers held under zoom levels other than `z`, then
21+
* return (or create) the controller bucket for `z`. Keeps the vector
22+
* loader's per-zoom bookkeeping in lockstep.
23+
*/
24+
function bucketForZoom(
25+
pending: PendingByZoom,
26+
z: number
27+
): Set<AbortController> {
28+
for (const [oldZ, controllers] of pending) {
29+
if (oldZ !== z) {
30+
for (const c of controllers) c.abort();
31+
pending.delete(oldZ);
32+
}
33+
}
34+
let set = pending.get(z);
35+
if (!set) {
36+
set = new Set();
37+
pending.set(z, set);
38+
}
39+
return set;
40+
}
41+
42+
function releaseController(
43+
pending: PendingByZoom,
44+
z: number,
45+
controller: AbortController
46+
) {
47+
const s = pending.get(z);
48+
if (s) {
49+
s.delete(controller);
50+
if (s.size === 0) pending.delete(z);
51+
}
52+
}
53+
54+
export function abortPendingControllers(pending: PendingByZoom): void {
55+
for (const controllers of pending.values()) {
56+
for (const controller of controllers) controller.abort();
57+
}
58+
pending.clear();
59+
}
60+
61+
/**
62+
* Build an OL VectorTile `tileLoadFunction` that aborts in-flight loads from
63+
* superseded zoom levels. When a new tile request arrives at zoom Z, any
64+
* outstanding controllers for zoom !== Z are aborted so they free up the
65+
* browser's connection pool for the now-visible level. The fetch is wrapped
66+
* inside `tile.setLoader` because OL's VectorTile contract has the tile own
67+
* its decoded features.
68+
*/
69+
export function createAbortableVectorTileLoader(): AbortableVectorTileLoader {
70+
const pending: PendingByZoom = new Map();
71+
72+
const tileLoadFunction: VectorTileLoadFunction = (tile, src) => {
73+
const z = tile.getTileCoord()[0];
74+
75+
tile.setLoader(
76+
(extent: Extent, _resolution: number, projection: Projection) => {
77+
const bucket = bucketForZoom(pending, z);
78+
const controller = new AbortController();
79+
bucket.add(controller);
80+
81+
tile.setState(TileState.LOADING);
82+
fetch(src, { signal: controller.signal })
83+
.then((r) => r.arrayBuffer())
84+
.then((data) => {
85+
const format = tile.getFormat();
86+
const features = format.readFeatures(data, {
87+
extent,
88+
featureProjection: projection
89+
}) as FeatureLike[];
90+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
91+
(tile as any).setFeatures(features);
92+
tile.setState(TileState.LOADED);
93+
})
94+
.catch(() => {
95+
tile.setState(TileState.ERROR);
96+
})
97+
.finally(() => {
98+
releaseController(pending, z, controller);
99+
});
100+
}
101+
);
102+
};
103+
104+
return {
105+
tileLoadFunction,
106+
abortPending: () => abortPendingControllers(pending)
107+
};
108+
}

src/app/modules/map/ol/lib/map.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,11 @@ export class MapComponent implements OnInit, OnDestroy {
171171
// outside the zone eliminates that; the specific handlers that must update
172172
// Angular state re-enter the zone via ngZone.run() (see below).
173173
this.ngZone.runOutsideAngular(() => {
174-
this.map = new Map();
174+
this.map = new Map({
175+
// 2x OL default (16); keeps the browser HTTP/1.1 per-host connection
176+
// pool fully fed during zoom-out tile bursts.
177+
maxTilesLoading: 32
178+
});
175179
this.map.setTarget(target);
176180
this.map.setProperties(this.properties, true);
177181
// register the map in the injectable mapService

0 commit comments

Comments
 (0)