Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
2f97f4a
Allow over zooming using geojson-vt
HarelM Oct 9, 2025
26807ca
Allow overzooming using geojson-vt
HarelM Oct 9, 2025
3490a30
Use map's max zoom
HarelM Oct 9, 2025
7ac64c8
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 9, 2025
cba4fd4
Vector source overzoom improvements (#6532)
wayofthefuture Oct 10, 2025
789a094
Reduce diff changes
HarelM Oct 12, 2025
26ba334
Improve comments
HarelM Oct 12, 2025
020e565
Simplify assignment
HarelM Oct 12, 2025
29c7476
Reduce diff changes
HarelM Oct 12, 2025
50fb9d2
Move code to a different file
HarelM Oct 12, 2025
11165bf
Move comment to method description
HarelM Oct 12, 2025
b643b16
Update some render test results
HarelM Oct 12, 2025
524270c
Update min test, more render tests
HarelM Oct 12, 2025
425fc24
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 12, 2025
1ac4695
Revert bug that was added...
HarelM Oct 12, 2025
b5ec5ce
Remove redundant code
HarelM Oct 12, 2025
8e334d7
Fix missing ID in overscaled tiles
HarelM Oct 12, 2025
2a6d8fa
Add changelog
HarelM Oct 12, 2025
82e3669
Revert change that will be introduced in another PR.
HarelM Oct 12, 2025
463e02f
Revert changes in worker_tile
HarelM Oct 12, 2025
97529d7
Update another render test
HarelM Oct 12, 2025
06fdc1b
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 12, 2025
b3292d3
Reduce changes in example
HarelM Oct 12, 2025
619a73b
Improve names in the example
HarelM Oct 12, 2025
5f06216
Improve docs
HarelM Oct 12, 2025
c7b66fa
Fix spelling
HarelM Oct 12, 2025
0f7a3ab
Add unit tests for LRU cache
HarelM Oct 12, 2025
e818b2d
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 12, 2025
32d599c
Fix lint
HarelM Oct 12, 2025
8508e61
Add experimental flag to default this behavior only on Safari.
HarelM Oct 12, 2025
a90f70e
Revert "Update some render test results"
HarelM Oct 12, 2025
342f53a
Revert "Update another render test"
HarelM Oct 12, 2025
dceabad
Revert "Update min test, more render tests"
HarelM Oct 12, 2025
52581cf
Revert last render test change
HarelM Oct 12, 2025
0b1a2d3
More detailed changelog message.
HarelM Oct 12, 2025
4e3379c
Update CHANGELOG.md
HarelM Oct 13, 2025
e3e2345
Merge branch '2507-overzoom-geojson-vt' of https://github.com/maplibr…
HarelM Oct 13, 2025
b9ec430
Update doc comment
HarelM Oct 13, 2025
e8f1749
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 13, 2025
48e44e0
Remove default
HarelM Oct 13, 2025
8ae4135
Update min test
HarelM Oct 13, 2025
a1ee81d
Added maxOverzoom to key
HarelM Oct 13, 2025
9a0efa7
Update src/ui/map.ts
HarelM Oct 13, 2025
7c7d1c6
Fix typo
HarelM Oct 13, 2025
a210bc6
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 14, 2025
f4d14cf
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 19, 2025
744dee0
Only clip tiles instead of using geojson index
HarelM Oct 21, 2025
6126261
Fix lint
HarelM Oct 21, 2025
5d509e6
Update expected bytes
HarelM Oct 21, 2025
7ccb2f7
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 21, 2025
9e619a0
Renames to match existing files
HarelM Oct 22, 2025
ac6b817
Update changelog
HarelM Oct 22, 2025
6fcccab
Update docs to remove geojson-vt where it's not relevant
HarelM Oct 22, 2025
da6c0e5
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 26, 2025
d80771a
Merge branch 'main' into 2507-overzoom-geojson-vt
HarelM Oct 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## main

### ✨ Features and improvements

- Slice vector tiles to improve over scale vector handling ([#6521](https://github.com/maplibre/maplibre-gl-js/pull/6521)) ⚠️ This is an experimental feature that currently only applies to Safari by default to prevent it from crashing at high zoom levels. This is not enabled by default for all browsers as it changes rendering of polygon features in high zoom levels due to tile splitting. It adds the `experimentalOverzoomingByClippingTiles` flag to `MapOptions` to allow controlling this behavior if needed. Use `true` for partitioning and `false` for scaling. It seems to have better performance at high zoom levels.
- _...Add new stuff here..._

### 🐞 Bug fixes
Expand Down
3 changes: 1 addition & 2 deletions src/data/bucket/symbol_bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {EvaluationParameters} from '../../style/evaluation_parameters';
import {Formatted, ResolvedImage} from '@maplibre/maplibre-gl-style-spec';
import {rtlWorkerPlugin} from '../../source/rtl_text_plugin_worker';
import {getOverlapMode} from '../../style/style_layer/overlap_mode';
import {isSafari} from '../../util/util';
import type {CanonicalTileID} from '../../source/tile_id';
import type {
Bucket,
Expand Down Expand Up @@ -363,7 +362,7 @@ export class SymbolBucket implements Bucket {
constructor(options: BucketParameters<SymbolStyleLayer>) {
this.collisionBoxArray = options.collisionBoxArray;
this.zoom = options.zoom;
this.overscaling = isSafari(globalThis) ? Math.min(options.overscaling, 128) : options.overscaling;
this.overscaling = options.overscaling;
this.layers = options.layers;
this.layerIds = this.layers.map(layer => layer.id);
this.index = options.index;
Expand Down
7 changes: 6 additions & 1 deletion src/geo/projection/covering_tiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export type CoveringTilesOptionsInternal = CoveringTilesOptions & {
* its zoom set to the overscaled greater zoom. When false, such tiles will have zoom set to `maxzoom`.
*/
reparseOverscaled?: boolean;
/**
* For vector tiles, geojsonvt is used to increase performance by providing sub-tile grids greater than source maxzoom,
* therefore bypassing the need for overscaling past source maxzoom using overscaled tileIDs.
*/
bypassOverscaling?: boolean;
/**
* When terrain is present, tile visibility will be computed in regards to the min and max elevations for each tile.
*/
Expand Down Expand Up @@ -191,7 +196,7 @@ export function coveringTiles(transform: IReadonlyTransform, options: CoveringTi

const desiredZ = coveringZoomLevel(transform, options);
const minZoom = options.minzoom || 0;
const maxZoom = options.maxzoom !== undefined ? options.maxzoom : transform.maxZoom;
const maxZoom = options.bypassOverscaling ? transform.maxZoom : options.maxzoom ?? transform.maxZoom;
const nominalZ = Math.min(Math.max(0, desiredZ), maxZoom);

const numTiles = Math.pow(2, nominalZ);
Expand Down
25 changes: 5 additions & 20 deletions src/source/geojson_worker_source.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import {getJSON} from '../util/ajax';
import {RequestPerformance} from '../util/performance';
import rewind from '@mapbox/geojson-rewind';
import {fromVectorTileJs, GeoJSONWrapper} from '@maplibre/vt-pbf';
import {GeoJSONWrapper} from '@maplibre/vt-pbf';
import {EXTENT} from '../data/extent';
import Supercluster, {type Options as SuperclusterOptions, type ClusterProperties} from 'supercluster';
import geojsonvt, {type Options as GeoJSONVTOptions} from 'geojson-vt';
import {VectorTileWorkerSource} from './vector_tile_worker_source';
import {createExpression} from '@maplibre/maplibre-gl-style-spec';
import {isAbortError} from '../util/abort_error';

import type {
WorkerTileParameters,
WorkerTileResult,
} from '../source/worker_source';

import {toVirtualVectorTile} from './vector_tile_overzoomed';
import {isUpdateableGeoJSON, type GeoJSONSourceDiff, applySourceDiff, toUpdateable, type GeoJSONFeatureId} from './geojson_source_diff';
import type {WorkerTileParameters, WorkerTileResult} from '../source/worker_source';
import type {LoadVectorTileResult} from './vector_tile_worker_source';
import type {RequestParameters} from '../util/ajax';
import {isUpdateableGeoJSON, type GeoJSONSourceDiff, applySourceDiff, toUpdateable, type GeoJSONFeatureId} from './geojson_source_diff';
import type {ClusterIDAndSource, GeoJSONWorkerSourceLoadDataResult, RemoveSourceParams} from '../util/actor_messages';
import type {IActor} from '../util/actor';
import type {StyleLayerIndex} from '../style/style_layer_index';
Expand Down Expand Up @@ -90,19 +86,8 @@ export class GeoJSONWorkerSource extends VectorTileWorkerSource {
}

const geojsonWrapper = new GeoJSONWrapper(geoJSONTile.features, {version: 2, extent: EXTENT});
// Encode the geojson-vt tile into binary vector tile form.
// This is a convenience that allows `FeatureIndex` to operate the same way
// across `VectorTileSource` and `GeoJSONSource` data.
let pbf = fromVectorTileJs(geojsonWrapper);
if (pbf.byteOffset !== 0 || pbf.byteLength !== pbf.buffer.byteLength) {
// Compatibility with node Buffer (https://github.com/mapbox/pbf/issues/35)
pbf = new Uint8Array(pbf);
}

return {
vectorTile: geojsonWrapper,
rawData: pbf.buffer
};
return toVirtualVectorTile(geojsonWrapper);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/source/source_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,8 @@ export class SourceCache extends Evented {
roundZoom: this.usedForTerrain ? false : this._source.roundZoom,
reparseOverscaled: this._source.reparseOverscaled,
terrain,
calculateTileZoom: this._source.calculateTileZoom
calculateTileZoom: this._source.calculateTileZoom,
bypassOverscaling: this._source.type === 'vector' && this.map._overzoomingByClippingTiles
});

if (this._source.hasTile) {
Expand Down
62 changes: 61 additions & 1 deletion src/source/tile_cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, test, expect} from 'vitest';
import {type Tile} from './tile';
import {TileCache} from './tile_cache';
import {TileCache, BoundedLRUCache} from './tile_cache';
import {OverscaledTileID} from './tile_id';

const idA = new OverscaledTileID(10, 0, 10, 0, 1);
Expand Down Expand Up @@ -129,3 +129,63 @@ describe('TileCache', () => {
expect(numRemoved).toBe(3);
});
});

describe('BoundedLRUCache', () => {
test('evicts least-recently-used item when capacity exceeded', () => {
const cache = new BoundedLRUCache<string, number>(2);

cache.set('a', 1);
cache.set('b', 2);

// Access 'a' to make it most-recently-used
expect(cache.get('a')).toBe(1);

// Insert 'c' -> should evict 'b' (the least recently used)
cache.set('c', 3);

expect(cache.get('b')).toBeUndefined();
expect(cache.get('a')).toBe(1);
expect(cache.get('c')).toBe(3);
});

test('setting an existing key updates value and makes it most-recently-used', () => {
const cache = new BoundedLRUCache<string, number>(2);

cache.set('a', 1);
cache.set('b', 2);

// Update 'a' value and it should become most-recently-used
cache.set('a', 10);
// Insert 'c' -> should evict 'b'
cache.set('c', 3);

expect(cache.get('b')).toBeUndefined();
expect(cache.get('a')).toBe(10);
expect(cache.get('c')).toBe(3);
});

test('capacity 1 evicts previous entry on new set', () => {
const cache = new BoundedLRUCache<string, string>(1);

cache.set('x', 'first');
expect(cache.get('x')).toBe('first');

cache.set('y', 'second');
expect(cache.get('x')).toBeUndefined();
expect(cache.get('y')).toBe('second');
});

test('clear removes all entries', () => {
const cache = new BoundedLRUCache<number, string>(3);
cache.set(1, 'one');
cache.set(2, 'two');

expect(cache.get(1)).toBe('one');
expect(cache.get(2)).toBe('two');

cache.clear();

expect(cache.get(1)).toBeUndefined();
expect(cache.get(2)).toBeUndefined();
});
});
35 changes: 35 additions & 0 deletions src/source/tile_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,38 @@ export class TileCache {
}
}
}

export class BoundedLRUCache<K, V> {
private maxEntries: number;
private map: Map<K, V>;

constructor(maxEntries: number) {
this.maxEntries = maxEntries;
this.map = new Map();
}

get(key: K): V | undefined {
const value = this.map.get(key);
if (value !== undefined) {
// Move key to end (most recently used)
this.map.delete(key);
this.map.set(key, value);
}
return value;
}

set(key: K, value: V): void {
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.maxEntries) {
// Delete oldest
const oldestKey = this.map.keys().next().value;
this.map.delete(oldestKey);
}
this.map.set(key, value);
}

clear(): void {
this.map.clear();
}
}
119 changes: 119 additions & 0 deletions src/source/vector_tile_overzoomed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import {type VectorTile, VectorTileFeature, VectorTileLayer} from '@mapbox/vector-tile';
import Protobuf from 'pbf';
import Point from '@mapbox/point-geometry';
import {fromVectorTileJs} from '@maplibre/vt-pbf';
import {clipGeometry} from '../symbol/clip_line';
import type {LoadVectorTileResult} from './vector_tile_worker_source';
import type {CanonicalTileID} from './tile_id';

class VectorTileFeatureOverzoomed extends VectorTileFeature {
pointsArray: Point[][];

constructor(type: 0 | 1 | 2 | 3, geometry: Point[][], properties: any, id: number, extent: number) {
super(new Protobuf(), 0, extent, [], []);
this.type = type;
this.properties = properties ? properties : {};
this.extent = extent;
this.pointsArray = geometry;
this.id = id;
}

Check warning on line 19 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L13-L19

These lines are not covered by a test

loadGeometry() {
// Clone the geometry and ensure all points are Point instances
return this.pointsArray.map(ring =>
ring.map(point => new Point(point.x, point.y))
);
}

Check warning on line 26 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L23-L26

These lines are not covered by a test
}

class VectorTileLayerOverzoomed extends VectorTileLayer {
private _myFeatures: VectorTileFeatureOverzoomed[];
name: string;
extent: number;
version: number = 2;
length: number;

constructor(features: VectorTileFeatureOverzoomed[], layerName: string, extent: number) {
super(new Protobuf());
this._myFeatures = features;
this.name = layerName;
this.length = features.length;
this.extent = extent;
}

Check warning on line 42 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L37-L42

These lines are not covered by a test

feature(i: number): VectorTileFeature {
return this._myFeatures[i];
}

Check warning on line 46 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L45-L46

These lines are not covered by a test
}

export class VectorTileOverzoomed implements VectorTile {
layers: Record<string, VectorTileLayer> = {};

Check warning on line 50 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L50

This line is not covered by a test

addLayer(layer: VectorTileLayerOverzoomed) {
this.layers[layer.name] = layer;
}

Check warning on line 54 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L53-L54

These lines are not covered by a test
}

/**
* Encodes the virtual tile into binary vector tile form.
* This is a convenience that allows `FeatureIndex` to operate the same way across `VectorTileSource` and `GeoJSONSource` data.
* @param virtualVectorTile - a syntetically created vector tile, this tile should have the relevant layer and features already added to it.
* @returns - the encoded vector tile along with the original virtual tile binary data.
*/
export function toVirtualVectorTile(virtualVectorTile: VectorTile): LoadVectorTileResult {
let pbf: Uint8Array = fromVectorTileJs(virtualVectorTile);
if (pbf.byteOffset !== 0 || pbf.byteLength !== pbf.buffer.byteLength) {
pbf = new Uint8Array(pbf); // Compatibility with node Buffer (https://github.com/mapbox/pbf/issues/35)
}
return {
vectorTile: virtualVectorTile,
rawData: pbf.buffer
};
}

/**
* This function slices a source tile layer into an overzoomed tile layer for a target tile ID.
* @param sourceLayer - the source tile layer to slice
* @param maxZoomTileID - the maximum zoom tile ID
* @param targetTileID - the target tile ID
* @returns - the overzoomed tile layer
*/
export function sliceVectorTileLayer(sourceLayer: VectorTileLayer, maxZoomTileID: CanonicalTileID, targetTileID: CanonicalTileID): VectorTileLayerOverzoomed {
const {extent} = sourceLayer;
const dz = targetTileID.z - maxZoomTileID.z;
const scale = Math.pow(2, dz);

Check warning on line 84 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L82-L84

These lines are not covered by a test

// Calculate the target tile's position within the source tile in target coordinate space
// This ensures all tiles share the same coordinate system
const offsetX = (targetTileID.x - maxZoomTileID.x * scale) * extent;
const offsetY = (targetTileID.y - maxZoomTileID.y * scale) * extent;

Check warning on line 89 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L88-L89

These lines are not covered by a test

const featureWrappers: VectorTileFeatureOverzoomed[] = [];
for (let index = 0; index < sourceLayer.length; index++) {
const feature: VectorTileFeature = sourceLayer.feature(index);
let geometry = feature.loadGeometry();

Check warning on line 94 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L91-L94

These lines are not covered by a test

// Transform all coordinates to target tile space
for (const ring of geometry) {
for (const point of ring) {
point.x = point.x * scale - offsetX;
point.y = point.y * scale - offsetY;
}
}

Check warning on line 102 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L97-L102

These lines are not covered by a test

const buffer = 128;
geometry = clipGeometry(geometry, feature.type, -buffer, -buffer, extent + buffer, extent + buffer);
if (geometry.length === 0) {
continue;
}

Check warning on line 108 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L104-L108

These lines are not covered by a test

featureWrappers.push(new VectorTileFeatureOverzoomed(
feature.type,
geometry,
feature.properties,
feature.id,
extent
));
}
return new VectorTileLayerOverzoomed(featureWrappers, sourceLayer.name, extent);
}

Check warning on line 119 in src/source/vector_tile_overzoomed.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_overzoomed.ts#L110-L119

These lines are not covered by a test
24 changes: 22 additions & 2 deletions src/source/vector_tile_source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import type {Dispatcher} from '../util/dispatcher';
import type {Tile} from './tile';
import type {VectorSourceSpecification, PromoteIdSpecification} from '@maplibre/maplibre-gl-style-spec';
import type {WorkerTileParameters, WorkerTileResult} from './worker_source';
import type {WorkerTileParameters, OverzoomParameters, WorkerTileResult} from './worker_source';
import {MessageType} from '../util/actor_messages';

export type VectorTileSourceOptions = VectorSourceSpecification & {
Expand Down Expand Up @@ -202,7 +202,8 @@
pixelRatio: this.map.getPixelRatio(),
showCollisionBoxes: this.map.showCollisionBoxes,
promoteId: this.promoteId,
subdivisionGranularity: this.map.style.projection.subdivisionGranularity
subdivisionGranularity: this.map.style.projection.subdivisionGranularity,
overzoomParameters: this._getOverzoomParameters(tile),
};
params.request.collectResourceTiming = this._collectResourceTiming;
let messageType: MessageType.loadTile | MessageType.reloadTile = MessageType.reloadTile;
Expand Down Expand Up @@ -236,6 +237,25 @@
}
}

/**
* When the requested tile has a higher canonical Z than source maxzoom, pass overzoom parameters so worker can load the
* deepest tile at source max zoom to generate sub tiles using geojsonvt for highest performance on vector overscaling
*/
private _getOverzoomParameters(tile: Tile): OverzoomParameters | undefined {
if (tile.tileID.canonical.z <= this.maxzoom) {
return undefined;
}
const maxZoomTileID = tile.tileID.scaledTo(this.maxzoom).canonical;
const maxZoomTileUrl = maxZoomTileID.url(this.tiles, this.map.getPixelRatio(), this.scheme);

return {
maxZoomTileID,
overzoomRequest: this.map._requestManager.transformRequest(maxZoomTileUrl, ResourceType.Tile),
maxOverzoom: this.map.getMaxZoom(),
tileSize: this.tileSize
};

Check warning on line 256 in src/source/vector_tile_source.ts

View workflow job for this annotation

GitHub Actions / Annotate

src/source/vector_tile_source.ts#L252-L256

These lines are not covered by a test
}

private _afterTileLoadWorkerResponse(tile: Tile, data: WorkerTileResult) {
if (data && data.resourceTiming) {
tile.resourceTiming = data.resourceTiming;
Expand Down
Loading