-
-
Couldn't load subscription status.
- Fork 916
Split tiles on-the-fly to improve over scale vector handling #6521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
All the failing render test are failing due to placement of labels, which is expected as part of this PR. |
* improvements * hacktober hangover * bounded lru cache, map.setMaxZoom compatibility * fix tile boundaries * remove space * bug * don't pregenerate needless tiles above maxzoom * isSafari, comments * copy geojson_source implementation --------- Co-authored-by: wayofthefuture <[email protected]>
|
I've enabled debug in geojson-vt and I see that it creates indexes from z0 to z14 where most of the data is duplicated, and there's a lot of points there (although nothing it shouldn't handle I believe). |
|
So I wrote the following code to "simply" split a tile and take the relevant geometry out of it.
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 {type LoadVectorTileResult} from './vector_tile_worker_source';
import { CanonicalTileID } from './tile_id';
class OverzoomedFeatureWrapper extends VectorTileFeature {
myGeometry: 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.myGeometry = geometry;
this.id = id;
}
loadGeometry() {
return structuredClone(this.myGeometry);
}
}
class OverzoomedTileLayer extends VectorTileLayer {
private _myFeatures: OverzoomedFeatureWrapper[];
name: string;
extent: number;
version: number = 2;
length: number;
constructor(features: OverzoomedFeatureWrapper[], layerName: string, extent: number) {
super(new Protobuf());
this._myFeatures = features;
this.name = layerName;
this.length = features.length;
this.extent = extent;
}
feature(i: number): VectorTileFeature {
return this._myFeatures[i];
}
}
export class OverzoomedVectorTile implements VectorTile {
layers: Record<string, VectorTileLayer> = {};
addLayer(layer: OverzoomedTileLayer) {
this.layers[layer.name] = layer;
}
}
/**
* 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 VectorTile created from GeoJSON data using geojson-vt
* @returns
*/
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
};
}
export function sliceTileLayer(sourceLayer: VectorTileLayer, maxZoomTileID: CanonicalTileID, targetTileID: CanonicalTileID): OverzoomedTileLayer {
const { extent } = sourceLayer;
if (extent !== 4096) {
console.log(`Warning: overzooming only tested with extent=4096, got extent=${extent}`);
}
const dz = targetTileID.z - maxZoomTileID.z;
const scale = Math.pow(2, dz);
const startXRelative = targetTileID.x / scale - maxZoomTileID.x;
const startYRelative = targetTileID.y / scale - maxZoomTileID.y;
const clipMinX = Math.round(startXRelative * extent);
const clipMinY = Math.round(startYRelative * extent);
const clipMaxX = Math.round((startXRelative + (1 / scale)) * extent);
const clipMaxY = Math.round((startYRelative + (1 / scale)) * extent);
// The clipping box coordinates within the source tile (0-extent)
const clippingBox = {
minX: clipMinX - 128,
minY: clipMinY - 128,
maxX: clipMaxX + 128,
maxY: clipMaxY + 128
};
const featureWrappers: OverzoomedFeatureWrapper[] = [];
for (let index = 0; index < sourceLayer.length; index++) {
const feature: VectorTileFeature = sourceLayer.feature(index);
let geometry = feature.loadGeometry();
geometry = clip(geometry, feature.type, clippingBox.minX, clippingBox.maxX, AxisType.X);
geometry = clip(geometry, feature.type, clippingBox.minY, clippingBox.maxY, AxisType.Y);
// scale coordinates to the new tile extent
for (const ring of geometry) {
for (const point of ring) {
point.x = (point.x - clipMinX) * scale;
point.y = (point.y - clipMinY) * scale;
}
}
if (geometry.length === 0) {
continue;
}
featureWrappers.push(new OverzoomedFeatureWrapper(
feature.type,
geometry,
feature.properties,
feature.id,
extent
));
}
return new OverzoomedTileLayer(featureWrappers, sourceLayer.name, extent);
}
const enum AxisType {
X = 0,
Y = 1
}
/* clip features between two vertical or horizontal axis-parallel lines:
* | |
* ___|___ | /
* / | \____|____/
* | |
*
* k1 and k2 are the line coordinates
* axis: 0 for x, 1 for y
* minAll and maxAll: minimum and maximum coordinate value for all features
*/
export default function clip(geometry: Point[][], type: number, start: number, end: number, axis: AxisType): Point[][] {
//const min = axis === AxisType.X ? geometry.map(ring => ring.reduce((min, p) => Math.min(min, p.x), Infinity)).reduce((a, b) => Math.min(a, b), Infinity) : geometry.map(ring => ring.reduce((min, p) => Math.min(min, p.y), Infinity)).reduce((a, b) => Math.min(a, b), Infinity);
//const max = axis === AxisType.Y ? geometry.map(ring => ring.reduce((max, p) => Math.max(max, p.y), -Infinity)).reduce((a, b) => Math.max(a, b), -Infinity) : geometry.map(ring => ring.reduce((max, p) => Math.max(max, p.x), -Infinity)).reduce((a, b) => Math.max(a, b), -Infinity);
//if (max < start || min >= end) { // trivial reject
// return [];
//}
switch (type) {
case 1: // POINT
return clipPoints(geometry, start, end, axis);
case 2: // LINESTRING
return clipLines(geometry, start, end, axis, false);
case 3: // POLYGON
return clipLines(geometry, start, end, axis, true);
}
return null;
}
function clipPoints(geometry: Point[][], start: number, end: number, axis: AxisType): Point[][] {
const newGeometry: Point[][] = [];
for (const ring of geometry) {
for (const point of ring) {
const a = axis === AxisType.X ? point.x : point.y;
if (a >= start && a <= end) {
newGeometry.push([point]);
}
}
}
return newGeometry;
}
function clipLine(line: Point[], start: number, end: number, axis: AxisType, isPolygon: boolean): Point[][] {
const intersect = axis === AxisType.X ? intersectX : intersectY;
let slice: Point[] = [];
const newLine: Point[][] = [];
for (let i = 0; i < line.length - 1; i++) {
const p1 = line[i];
const p2 = line[i + 1];
const a = axis === AxisType.X ? p1.x : p1.y;
const b = axis === AxisType.X ? p2.x : p2.y;
let exited = false;
if (a < start) {
// ---|--> | (line enters the clip region from the left)
if (b > start) {
slice.push(intersect(p1, p2, start));
}
} else if (a > end) {
// | <--|--- (line enters the clip region from the right)
if (b < end) {
slice.push(intersect(p1, p2, end));
}
} else {
slice.push(p1);
}
if (b < start && a >= start) {
// <--|--- | or <--|-----|--- (line exits the clip region on the left)
slice.push(intersect(p1, p2, start));
exited = true;
}
if (b > end && a <= end) {
// | ---|--> or ---|-----|--> (line exits the clip region on the right)
slice.push(intersect(p1, p2, end));
exited = true;
}
if (!isPolygon && exited) {
newLine.push(slice);
slice = [];
}
}
// add the last point
let last = line.length - 1;
const a = axis === AxisType.X ? line[last].x : line[last].y;
if (a >= start && a <= end) {
slice.push(line[last]);
}
// close the polygon if its endpoints are not the same after clipping
if (isPolygon && slice.length > 0 && !slice[0].equals(slice[slice.length - 1])) {
slice.push(new Point(slice[0].x, slice[0].y));
}
if (slice.length > 0) {
newLine.push(slice);
}
return newLine;
}
function clipLines(geometry: Point[][], start: number, end: number, axis: AxisType, isPolygon: boolean): Point[][] {
const newGeometry: Point[][] = [];
for (const line of geometry) {
const clippedLines = clipLine(line, start, end, axis, isPolygon);
if (clippedLines.length > 0) {
newGeometry.push(...clippedLines);
}
}
return newGeometry;
}
function intersectX(p1: Point, p2: Point, x: number): Point {
const t = (x - p1.x) / (p2.x - p1.x);
return new Point(x, p1.y + (p2.y - p1.y) * t);
}
function intersectY(p1: Point, p2: Point, y: number): Point {
const t = (y - p1.y) / (p2.y - p1.y);
return new Point(p1.x + (p2.x - p1.x) * t, y);
} |
|
Nice! Thanks for this! So this was about when to do the coordinates conversion basically? |
|
Yes... I didn't get a chance to calculate performance but processing could be heavy and it is a nice o(1) lru cache that we can also use in source cache eventually. It's simple and I don't see how it wouldn't improve performance. |
|
I think the problem here is that iOS might kill things that use too much memory, this is what I think is causing the crash with the geojson index, although it might be a magnitude bigger... |
|
Many magnitudes larger especially with tolerance: 0. I was able to monitor memory with the worker, and the previous version was growing well over 10GB in memory and quickly. The current version with 10,000 (notice I added a 0!) in cache didn't exceed 2GB with heavy automated movement. With 0 cache the page was about 1GB in total. You had 1 pyramid per layer per max zoom tile, that's like n^n^n or something! The current cache is simply flat vector tiles so I think it will work nicely. |
|
I found this when searching for something related to clipping in the code base as I assumed there should already be something: It's not exactly the same, but looks very similar, I wonder if I can use it or adopt it to our needs... |
|
I moved the clipping code to the same file as the other method. |
|
Thanks for your work on this @HarelM.
I'm not a fan of having the library function differently on different browsers like this. We should set a clear expectation to users about what
I think we should just accept that geometries returned from queryRenderedFeatures at an overzoom will have further clipping applied beyond the sources maxZoom. That should still represent the geometries visible on the map and therefore works the same as any other zoom level under the maxZoom of the source. |
Neither am I but currently Safari crashes while Chrome doesn't, so the difference is already there to some extent. The main difference I saw is for areas that has a label placed in the middle, this label is now repeated in every sliced tile, it becomes apparent in high zoom levels (20+) since this is most of what you see in the screen, since there's not a lot of data in one screen. I suggest to try the branch out with a style your are familiar with and see if you can spot the changes. |
|
Ok, So I wanted to polish this a bit more and I think I came up with an idea to set the number of zoom level to overscale instead of doing it for all zoom levels. |
|
So if you have map tile boundaries enabled would you see it switch from 1 tile to 256 subdivided tiles at maxzoom + 4 -> maxzoom + 5? Not sure how such a switch would look and it could have drastic style changes maybe? |
|
I'll push it to a different branch so you could play with it if you want. Source max zoom is 10. In that case the slicing will happen for zoom 11 to 17, and after that, a level 17 "sliced tile" will be used as the base for over scaling, similar to what happens now with the tiles at max zoom, only in this implementation we first slice the tile and later on, if needed scale it. It gives more control on when to stop slicing and start scaling. |
|
See the following PR: |
|
Got it I was thinking of it backwards... |

Launch Checklist
This is an experimental feature allowing to over-scale vector tiles by splitting them instead of over scaling them.
It is only used by default for Safari because it changes a bit the results of query render features geometry, since the tiles are no longer capped at max zoom, so the geometry is sliced as well in high zoom tiles, thus creating a "sliced" geometries, while this might be expected up to max-zoom, this is might not be expected in higher zoom levels.
It also changes the rendering of labels in the middle of areas for the same reasons.
The performance of this seems to work well and it solves the crash on iOS as far as my tests went.
What is in this PR:
Once an over-scaled tile request is sent, the code slices the original tile and returns it.
This is very similar to how geojson works, but geojson works only on a single layer, while this is working on multiple layers and doesn't create an index, just a small cache.
Some of the code here was copied from geojson-vt package.
The
experimentalOverzoomingWithGeojsonVtflag was added to allow controlling this behavior and avoid backward compatibility issues.It is applied by default to Safari browser to avoid the crash that is linked at the beginning of this post.
CC: @wayofthefuture @ChrisLoer
CHANGELOG.mdunder the## mainsection.