leaflet-vector-offline
Version:
Plugin for Leaflet.js that supports offline vector map tiles.
397 lines (396 loc) • 14.9 kB
JavaScript
import { getTileImageSource, reflect, timer } from "./utils";
import { themes } from "./themes";
import { getTilePoints, getTileUrl } from "leaflet.offline";
import * as protomapsLeaflet from "protomaps-leaflet";
export class VectorOfflineLayer extends L.TileLayer {
backgroundColor;
debug;
labelers;
labelRules;
lang;
lastRequestedZ;
paintRules;
scratch;
sourcePriority;
tasks;
tileDelay;
tileSize;
views;
_url;
constructor(url, options) {
if (options.noWrap && !options.bounds)
options.bounds = [
[-90, -180],
[90, 180],
];
if (options.attribution == null)
options.attribution =
'<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>';
options = { ...options, url: url };
super(url, options);
// This should be set within `TileLayer.initialize()` but for some reason,
// it only sets the variable locally. Therefore, we reset this._url here.
this._url = url;
this.sourcePriority = options.priority || "both";
if (options.theme) {
const themeName = options.theme || "light";
const theme = themes[themeName];
this.paintRules = protomapsLeaflet.paintRules(theme);
this.labelRules = protomapsLeaflet.labelRules(theme);
this.backgroundColor = theme.background;
}
else {
this.paintRules = options.paintRules || [];
this.labelRules = options.labelRules || [];
this.backgroundColor = options.backgroundColor;
}
this.lastRequestedZ = undefined;
this.tasks = options.tasks || [];
this.views = protomapsLeaflet.sourcesToViews(options);
this.debug = options.debug;
const scratch = document
.createElement("canvas")
.getContext("2d");
this.scratch = scratch;
this.labelers = new protomapsLeaflet.Labelers(this.scratch, this.labelRules, 16, (tiles) => {
for (const t of tiles) {
this.rerenderTile(t);
}
});
this.tileSize = 256 * window.devicePixelRatio;
this.tileDelay = options.tileDelay || 3;
this.lang = options.lang;
}
/**
* Clear the layout of the labels. This is useful when the map is panned or
* zoomed, and the labels need to be re-laid out.
*/
clearLayout() {
this.labelers = new protomapsLeaflet.Labelers(this.scratch, this.labelRules, 16, (tiles) => {
for (const t of tiles) {
this.rerenderTile(t);
}
});
}
/**
* Create a tile element for the given coordinates and callback when it's
* ready.
*
* @param coords Coordinates of the tile
* @param done Callback function
*
* @returns HTMLCanvasElement of the vector tile.
*/
createTile(coords, done) {
const tile = L.DomUtil.create("canvas", "leaflet-tile");
tile.lang = this.lang || "";
const offlineKey = this._getStorageKey(coords);
const onlineKey = this._tileCoordsToKey(coords);
getTileImageSource(this._getStorageKey(coords), this._url).then((value) => {
const [url, fromOnline] = value;
let key;
if (this.options.verbose) {
console.log(`Coords: ${coords},\nOffline key: ${offlineKey},\nOnline key: ${onlineKey},\nfromOnline: ${fromOnline},\nsourcePriority: ${this.sourcePriority}`);
}
if (fromOnline &&
(this.sourcePriority === "both" || this.sourcePriority === "online")) {
key = onlineKey;
}
else if (!fromOnline &&
(this.sourcePriority === "both" || this.sourcePriority === "offline")) {
key = offlineKey;
}
if (key) {
tile.key = key;
this.renderTile(coords, tile, key, url, () => {
done(undefined, tile);
});
}
});
if (this.options.verbose) {
tile.style.border = "1px solid red";
}
return tile;
}
/**
* Get the tile url for the given bounds and zoom level.
*
* @param bounds Tile bounds.
* @param zoom Zoom level.
*
* @returns Array of TileInfo objects.
*/
getTileUrls(bounds, zoom) {
const tiles = [];
const tilePoints = getTilePoints(bounds, this.getTileSize());
tilePoints.forEach((tilePoint) => {
const data = {
...this.options,
x: Math.floor(tilePoint.x * 2 ** this.options.zoomOffset),
y: Math.floor(tilePoint.y * 2 ** this.options.zoomOffset),
z: zoom + (this.options.zoomOffset || 0),
};
tiles.push({
key: getTileUrl(this._url, {
...data,
s: this.options.subdomains?.[0],
}),
url: getTileUrl(this._url, {
...data,
// // @ts-ignore: Undefined
s: this._getSubdomain(tilePoint),
}),
z: zoom,
x: tilePoint.x,
y: tilePoint.y,
urlTemplate: this._url,
createdAt: Date.now(),
});
});
return tiles;
}
/**
* Render the tile for the given coordinates using vector data.
*
* @param coords The coordinates of the tile.
* @param element The HTMLCanvasElement of the tile.
* @param key The key of the tile to render. This is dependent on the source
* of the tile data. I.e., if the tile data is from the offline source or the
* online source.
* @param url The URL of the tile data.
* @param done callback function to be called when the tile is rendered.
*/
async renderTile(coords, element, key, url, done = () => { }) {
if (this.options.verbose) {
console.log(`Rendering Element key: ${element.key},\nkey: ${key},\nlastRequestedZ: ${this.lastRequestedZ},\ncoords.z: ${coords.z}`);
}
this.views = protomapsLeaflet.sourcesToViews({ ...this.options, url });
this.lastRequestedZ = coords.z;
const promises = [];
for (const [key, view] of this.views) {
const promise = view.getDisplayTile(coords);
promises.push({ key: key, promise: promise });
}
const tileResponses = await Promise.all(promises.map((object) => {
return object.promise.then((v) => {
const response = {
status: "fulfilled",
value: v,
key: object.key,
};
return response;
}, (error) => {
const response = {
status: "rejected",
reason: error,
key: object.key,
};
return response;
});
}));
const preparedTilemap = new Map();
tileResponses.forEach((tileResponse) => {
if (tileResponse.status === "fulfilled") {
preparedTilemap.set(tileResponse.key, [tileResponse.value]);
}
else if (tileResponse.status === "rejected") {
if (tileResponse.reason.name !== "AbortError") {
console.error(`ERROR: (${tileResponse.reason.name}) ${tileResponse.reason}`);
}
}
else {
console.error('TYPE ERROR: `tileResponse.status` has valued that is not "fulfilled" or "rejected"');
}
});
if (!this._validateKey(element, key, coords))
return;
await Promise.all(this.tasks.map(reflect));
if (!this._validateKey(element, key, coords))
return;
const layoutTime = this.labelers.add(coords.z, preparedTilemap);
if (!this._validateKey(element, key, coords))
return;
const labelData = this.labelers.getIndex(coords.z);
if (!this._map)
return;
const center = this._map.getCenter().wrap();
const pixelBounds = this._getTiledPixelBounds(center);
const tileRange = this._pxBoundsToTileRange(pixelBounds);
const tileCenter = tileRange.getCenter();
const priority = coords.distanceTo(tileCenter) * this.tileDelay;
await timer(priority);
if (!this._validateKey(element, key, coords))
return;
const buf = 16;
const bbox = {
minX: 256 * coords.x - buf,
minY: 256 * coords.y - buf,
maxX: 256 * (coords.x + 1) + buf,
maxY: 256 * (coords.y + 1) + buf,
};
const origin = new L.Point(256 * coords.x, 256 * coords.y);
element.width = this.tileSize;
element.height = this.tileSize;
const ctx = element.getContext("2d");
if (!ctx) {
console.error("Failed to get Canvas context");
return;
}
ctx.setTransform(this.tileSize / 256, 0, 0, this.tileSize / 256, 0, 0);
ctx.clearRect(0, 0, 256, 256);
if (this.backgroundColor) {
ctx.save();
ctx.fillStyle = this.backgroundColor;
ctx.fillRect(0, 0, 256, 256);
ctx.restore();
}
let paintingTime = 0;
const paintRules = this.paintRules;
paintingTime = protomapsLeaflet.paint(ctx, coords.z, preparedTilemap, this.xray ? null : labelData, paintRules, bbox, origin, false, this.debug);
if (this.debug) {
ctx.save();
ctx.fillStyle = this.debug;
ctx.font = "600 12px sans-serif";
ctx.fillText(`${coords.z} ${coords.x} ${coords.y}`, 4, 14);
ctx.font = "12px sans-serif";
let ypos = 28;
for (const [k, v] of preparedTilemap) {
const dt = v[0].dataTile;
ctx.fillText(`${k + (k ? " " : "") + dt.z} ${dt.x} ${dt.y}`, 4, ypos);
ypos += 14;
}
ctx.font = "600 10px sans-serif";
if (paintingTime > 8) {
ctx.fillText(`${paintingTime.toFixed()} ms paint`, 4, ypos);
ypos += 14;
}
if (layoutTime > 8) {
ctx.fillText(`${layoutTime.toFixed()} ms layout`, 4, ypos);
}
ctx.strokeStyle = this.debug;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 256);
ctx.stroke();
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(256, 0);
ctx.stroke();
ctx.restore();
}
done();
}
/**
* Rerender all the tiles.
*/
rerenderTiles() {
for (const unwrappedK in this._tiles) {
const wrappedCoord = this._wrapCoords(this._keyToTileCoords(unwrappedK));
const key = this._tileCoordsToKey(wrappedCoord);
this.renderTile(wrappedCoord, this._tiles[unwrappedK].el, key, this._url);
}
}
/**
* Rerender the tile with the given key.
*
* @param key The key of the tile to rerender.
*/
rerenderTile(key) {
for (const unwrappedK in this._tiles) {
const wrappedCoord = this._wrapCoords(this._keyToTileCoords(unwrappedK));
if (key === this._tileCoordsToKey(wrappedCoord)) {
this.renderTile(wrappedCoord, this._tiles[unwrappedK].el, key, this._url);
}
}
}
/**
* Query the features at the given point.
*
* A primitive way to check the features at a certain point. It does not
* support hover states, cursor changes, or changing the style of the selected
* feature, so is only appropriate for debuggging or very basic use cases.
* Those features are outside of the scope of this library: for fully
* pickable, interactive features, use MapLibre GL JS instead.
*
* @param lng longitude of the point to query
* @param lat latitude of the point to query
* @param brushSize brush size to query the features
* @returns Map of source name to the features at the given point.
*/
queryTileFeaturesDebug(lng, lat, brushSize = 16) {
const featuresBySourceName = new Map();
for (const [sourceName, view] of this.views) {
featuresBySourceName.set(sourceName, view.queryFeatures(lng, lat, this._map.getZoom(), brushSize));
}
return featuresBySourceName;
}
/**
* Remove the tile with the given key.
*
* @param key key of the tile to remove.
*/
_removeTile(key) {
const tile = this._tiles[key];
if (!tile) {
return;
}
tile.el.removed = true;
tile.el.key = undefined;
L.DomUtil.removeClass(tile.el, "leaflet-tile-loaded");
tile.el.width = tile.el.height = 0;
L.DomUtil.remove(tile.el);
delete this._tiles[key];
this.fire("tileunload", {
tile: tile.el,
coords: this._keyToTileCoords(key),
});
}
/**
* Get the subdomain for the given point.
*
* @param coords The coordinates of the tile to get the subdomain.
*
* @returns The subdomain for the given point.
*/
_getStorageKey(coords) {
return getTileUrl(this._url, {
...coords,
...this.options,
x: Math.floor(coords.x * 2 ** this.options.zoomOffset),
y: Math.floor(coords.y * 2 ** this.options.zoomOffset),
z: coords.z + (this.options.zoomOffset || 0),
// // @ts-ignore: Possibly undefined
s: this.options.subdomains["0"],
});
}
_validateKey(element, key, coords) {
if (this.options.verbose) {
console.log(`Element key: ${element.key},\nkey: ${key},\nlastRequestedZ: ${this.lastRequestedZ},\ncoords.z: ${coords.z}`);
}
if (element.key !== key) {
return false;
}
if (this.lastRequestedZ !== coords.z) {
return false;
}
return true;
}
}
/**
* Create a new VectorOfflineLayer object.
*
* @param url The URL of the vector tile.
* @param options The options for the vector map.
*
* @returns A new VectorOfflineLayer object.
*/
export function vectorOfflineLayer(url, options) {
return new VectorOfflineLayer(url, options);
}
// // @ts-ignore
if (window.L) {
// @ts-expect-error type `offline` does not exist on type `tileLayer`.
window.L.tileLayer.offline = vectorOfflineLayer;
}