Add progressive web app companion for cross-platform access
Vite + TypeScript PWA that mirrors the Android app's core features: - Pre-processed shelter data (build-time UTM33N→WGS84 conversion) - Leaflet map with shelter markers, user location, and offline tiles - Canvas compass arrow (ported from DirectionArrowView.kt) - IndexedDB shelter cache with 7-day staleness check - Service worker with CacheFirst tiles and precached app shell - i18n for en, nb, nn (ported from Android strings.xml) - iOS/Android compass handling with low-pass filter - Respects user map interaction (no auto-snap on pan/zoom) - Build revision cache-breaker for reliable SW updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46365b713b
commit
e8428de775
12051 changed files with 1799735 additions and 0 deletions
267
pwa/node_modules/leaflet.offline/src/ControlSaveTiles.ts
generated
vendored
Normal file
267
pwa/node_modules/leaflet.offline/src/ControlSaveTiles.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import {
|
||||
Control,
|
||||
ControlOptions,
|
||||
DomEvent,
|
||||
DomUtil,
|
||||
LatLngBounds,
|
||||
bounds,
|
||||
Map,
|
||||
} from 'leaflet';
|
||||
import { TileLayerOffline } from './TileLayerOffline';
|
||||
import {
|
||||
truncate,
|
||||
getStorageLength,
|
||||
downloadTile,
|
||||
saveTile,
|
||||
TileInfo,
|
||||
hasTile,
|
||||
} from './TileManager';
|
||||
|
||||
export interface SaveTileOptions extends ControlOptions {
|
||||
saveText: string;
|
||||
rmText: string;
|
||||
maxZoom: number;
|
||||
saveWhatYouSee: boolean;
|
||||
bounds: LatLngBounds | null;
|
||||
confirm: ((status: SaveStatus, successCallback: Function) => void) | null;
|
||||
confirmRemoval:
|
||||
| ((status: SaveStatus, successCallback: Function) => void)
|
||||
| null;
|
||||
parallel: number;
|
||||
zoomlevels?: number[];
|
||||
alwaysDownload: boolean;
|
||||
}
|
||||
|
||||
export interface SaveStatus {
|
||||
_tilesforSave: TileInfo[];
|
||||
storagesize: number;
|
||||
lengthToBeSaved: number;
|
||||
lengthSaved: number;
|
||||
lengthLoaded: number;
|
||||
}
|
||||
|
||||
export class ControlSaveTiles extends Control {
|
||||
_map!: Map;
|
||||
|
||||
_refocusOnMap!: DomEvent.EventHandlerFn;
|
||||
|
||||
_baseLayer!: TileLayerOffline;
|
||||
|
||||
options: SaveTileOptions;
|
||||
|
||||
status: SaveStatus = {
|
||||
storagesize: 0,
|
||||
lengthToBeSaved: 0,
|
||||
lengthSaved: 0,
|
||||
lengthLoaded: 0,
|
||||
_tilesforSave: [],
|
||||
};
|
||||
|
||||
constructor(baseLayer: TileLayerOffline, options: Partial<SaveTileOptions>) {
|
||||
super(options);
|
||||
this._baseLayer = baseLayer;
|
||||
this.setStorageSize();
|
||||
this.options = {
|
||||
...{
|
||||
position: 'topleft',
|
||||
saveText: '+',
|
||||
rmText: '-',
|
||||
maxZoom: 19,
|
||||
saveWhatYouSee: false,
|
||||
bounds: null,
|
||||
confirm: null,
|
||||
confirmRemoval: null,
|
||||
parallel: 50,
|
||||
zoomlevels: undefined,
|
||||
alwaysDownload: true,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
setStorageSize() {
|
||||
if (this.status.storagesize) {
|
||||
return Promise.resolve(this.status.storagesize);
|
||||
}
|
||||
return getStorageLength()
|
||||
.then((numberOfKeys) => {
|
||||
this.status.storagesize = numberOfKeys;
|
||||
this._baseLayer.fire('storagesize', this.status);
|
||||
return numberOfKeys;
|
||||
})
|
||||
.catch(() => 0);
|
||||
}
|
||||
|
||||
getStorageSize(callback: Function) {
|
||||
this.setStorageSize().then((result) => {
|
||||
if (callback) {
|
||||
callback(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLayer(layer: TileLayerOffline) {
|
||||
this._baseLayer = layer;
|
||||
}
|
||||
|
||||
onAdd() {
|
||||
const container = DomUtil.create('div', 'savetiles leaflet-bar');
|
||||
const { options } = this;
|
||||
this._createButton(
|
||||
options.saveText,
|
||||
'savetiles',
|
||||
container,
|
||||
this._saveTiles,
|
||||
);
|
||||
this._createButton(options.rmText, 'rmtiles', container, this._rmTiles);
|
||||
return container;
|
||||
}
|
||||
|
||||
_createButton(
|
||||
html: string,
|
||||
className: string,
|
||||
container: HTMLElement,
|
||||
fn: DomEvent.EventHandlerFn,
|
||||
) {
|
||||
const link = DomUtil.create('a', className, container);
|
||||
link.innerHTML = html;
|
||||
link.href = '#';
|
||||
link.ariaRoleDescription = 'button';
|
||||
|
||||
DomEvent.on(link, 'mousedown dblclick', DomEvent.stopPropagation)
|
||||
.on(link, 'click', DomEvent.stop)
|
||||
.on(link, 'click', fn, this)
|
||||
.on(link, 'click', this._refocusOnMap, this);
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
_saveTiles() {
|
||||
const tiles = this._calculateTiles();
|
||||
this._resetStatus(tiles);
|
||||
const successCallback = async () => {
|
||||
this._baseLayer.fire('savestart', this.status);
|
||||
const loader = async (): Promise<void> => {
|
||||
const tile = tiles.shift();
|
||||
if (tile === undefined) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const blob = await this._loadTile(tile);
|
||||
if (blob) {
|
||||
await this._saveTile(tile, blob);
|
||||
}
|
||||
return loader();
|
||||
};
|
||||
const parallel = Math.min(tiles.length, this.options.parallel);
|
||||
for (let i = 0; i < parallel; i += 1) {
|
||||
loader();
|
||||
}
|
||||
};
|
||||
if (this.options.confirm) {
|
||||
this.options.confirm(this.status, successCallback);
|
||||
} else {
|
||||
successCallback();
|
||||
}
|
||||
}
|
||||
|
||||
_calculateTiles() {
|
||||
let tiles: TileInfo[] = [];
|
||||
// minimum zoom to prevent the user from saving the whole world
|
||||
const minZoom = 5;
|
||||
// current zoom or zoom options
|
||||
let zoomlevels = [];
|
||||
|
||||
if (this.options.saveWhatYouSee) {
|
||||
const currentZoom = this._map.getZoom();
|
||||
if (currentZoom < minZoom) {
|
||||
throw new Error(
|
||||
`It's not possible to save with zoom below level ${minZoom}.`,
|
||||
);
|
||||
}
|
||||
const { maxZoom } = this.options;
|
||||
|
||||
for (let zoom = currentZoom; zoom <= maxZoom; zoom += 1) {
|
||||
zoomlevels.push(zoom);
|
||||
}
|
||||
} else {
|
||||
zoomlevels = this.options.zoomlevels || [this._map.getZoom()];
|
||||
}
|
||||
|
||||
const latlngBounds = this.options.bounds || this._map.getBounds();
|
||||
|
||||
for (let i = 0; i < zoomlevels.length; i += 1) {
|
||||
const area = bounds(
|
||||
this._map.project(latlngBounds.getNorthWest(), zoomlevels[i]),
|
||||
this._map.project(latlngBounds.getSouthEast(), zoomlevels[i]),
|
||||
);
|
||||
tiles = tiles.concat(this._baseLayer.getTileUrls(area, zoomlevels[i]));
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
_resetStatus(tiles: TileInfo[]) {
|
||||
this.status = {
|
||||
lengthLoaded: 0,
|
||||
lengthToBeSaved: tiles.length,
|
||||
lengthSaved: 0,
|
||||
_tilesforSave: tiles,
|
||||
storagesize: this.status.storagesize,
|
||||
};
|
||||
}
|
||||
|
||||
async _loadTile(tile: TileInfo) {
|
||||
let blob;
|
||||
if (
|
||||
this.options.alwaysDownload === true ||
|
||||
(await hasTile(tile.key)) === false
|
||||
) {
|
||||
blob = await downloadTile(tile.url);
|
||||
}
|
||||
|
||||
this.status.lengthLoaded += 1;
|
||||
|
||||
this._baseLayer.fire('loadtileend', this.status);
|
||||
if (this.status.lengthLoaded === this.status.lengthToBeSaved) {
|
||||
this._baseLayer.fire('loadend', this.status);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
async _saveTile(tile: TileInfo, blob: Blob): Promise<void> {
|
||||
await saveTile(tile, blob);
|
||||
this.status.lengthSaved += 1;
|
||||
this._baseLayer.fire('savetileend', this.status);
|
||||
if (this.status.lengthSaved === this.status.lengthToBeSaved) {
|
||||
this._baseLayer.fire('saveend', this.status);
|
||||
this.setStorageSize();
|
||||
}
|
||||
}
|
||||
|
||||
_rmTiles() {
|
||||
const successCallback = () => {
|
||||
truncate().then(() => {
|
||||
this.status.storagesize = 0;
|
||||
this._baseLayer.fire('tilesremoved');
|
||||
this._baseLayer.fire('storagesize', this.status);
|
||||
});
|
||||
};
|
||||
if (this.options.confirmRemoval) {
|
||||
this.options.confirmRemoval(this.status, successCallback);
|
||||
} else {
|
||||
successCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function savetiles(
|
||||
baseLayer: TileLayerOffline,
|
||||
options: Partial<SaveTileOptions>,
|
||||
) {
|
||||
return new ControlSaveTiles(baseLayer, options);
|
||||
}
|
||||
|
||||
/** @ts-ignore */
|
||||
if (window.L) {
|
||||
/** @ts-ignore */
|
||||
window.L.control.savetiles = savetiles;
|
||||
}
|
||||
101
pwa/node_modules/leaflet.offline/src/TileLayerOffline.ts
generated
vendored
Normal file
101
pwa/node_modules/leaflet.offline/src/TileLayerOffline.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import {
|
||||
Bounds,
|
||||
Coords,
|
||||
DomEvent,
|
||||
DoneCallback,
|
||||
TileLayer,
|
||||
TileLayerOptions,
|
||||
Util,
|
||||
} from 'leaflet';
|
||||
import {
|
||||
getTileUrl,
|
||||
TileInfo,
|
||||
getTilePoints,
|
||||
getTileImageSource,
|
||||
} from './TileManager';
|
||||
|
||||
export class TileLayerOffline extends TileLayer {
|
||||
_url!: string;
|
||||
|
||||
createTile(coords: Coords, done: DoneCallback): HTMLElement {
|
||||
const tile = document.createElement('img');
|
||||
|
||||
DomEvent.on(tile, 'load', Util.bind(this._tileOnLoad, this, done, tile));
|
||||
DomEvent.on(tile, 'error', Util.bind(this._tileOnError, this, done, tile));
|
||||
|
||||
if (this.options.crossOrigin || this.options.crossOrigin === '') {
|
||||
tile.crossOrigin =
|
||||
this.options.crossOrigin === true ? '' : this.options.crossOrigin;
|
||||
}
|
||||
|
||||
tile.alt = '';
|
||||
|
||||
tile.setAttribute('role', 'presentation');
|
||||
|
||||
getTileImageSource(
|
||||
this._getStorageKey(coords),
|
||||
this.getTileUrl(coords),
|
||||
).then((src) => (tile.src = src));
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
/**
|
||||
* get key to use for storage
|
||||
* @private
|
||||
* @param {string} url url used to load tile
|
||||
* @return {string} unique identifier.
|
||||
*/
|
||||
_getStorageKey(coords: { x: number; y: number; z: number }) {
|
||||
return getTileUrl(this._url, {
|
||||
...coords,
|
||||
...this.options,
|
||||
// @ts-ignore: Possibly undefined
|
||||
s: this.options.subdomains['0'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tileinfo for zoomlevel & bounds
|
||||
*/
|
||||
getTileUrls(bounds: Bounds, zoom: number): TileInfo[] {
|
||||
const tiles: TileInfo[] = [];
|
||||
const tilePoints = getTilePoints(bounds, this.getTileSize());
|
||||
for (let index = 0; index < tilePoints.length; index += 1) {
|
||||
const tilePoint = tilePoints[index];
|
||||
const data = {
|
||||
...this.options,
|
||||
x: tilePoint.x,
|
||||
y: tilePoint.y,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function tileLayerOffline(url: string, options: TileLayerOptions) {
|
||||
return new TileLayerOffline(url, options);
|
||||
}
|
||||
|
||||
/** @ts-ignore */
|
||||
if (window.L) {
|
||||
/** @ts-ignore */
|
||||
window.L.tileLayer.offline = tileLayerOffline;
|
||||
}
|
||||
226
pwa/node_modules/leaflet.offline/src/TileManager.ts
generated
vendored
Normal file
226
pwa/node_modules/leaflet.offline/src/TileManager.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* Api methods used in control and layer
|
||||
* For advanced usage
|
||||
*
|
||||
* @module TileManager
|
||||
*
|
||||
*/
|
||||
|
||||
import { Bounds, Browser, CRS, Point, Util } from 'leaflet';
|
||||
import { openDB, deleteDB, IDBPDatabase } from 'idb';
|
||||
import { FeatureCollection, Polygon } from 'geojson';
|
||||
|
||||
export type TileInfo = {
|
||||
key: string;
|
||||
url: string;
|
||||
urlTemplate: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type StoredTile = TileInfo & { blob: Blob };
|
||||
|
||||
const tileStoreName = 'tileStore';
|
||||
const urlTemplateIndex = 'urlTemplate';
|
||||
let dbPromise: Promise<IDBPDatabase> | undefined;
|
||||
|
||||
export function openTilesDataBase(): Promise<IDBPDatabase> {
|
||||
if (dbPromise) {
|
||||
return dbPromise;
|
||||
}
|
||||
dbPromise = openDB('leaflet.offline', 2, {
|
||||
upgrade(db, oldVersion) {
|
||||
deleteDB('leaflet_offline');
|
||||
deleteDB('leaflet_offline_areas');
|
||||
|
||||
if (oldVersion < 1) {
|
||||
const tileStore = db.createObjectStore(tileStoreName, {
|
||||
keyPath: 'key',
|
||||
});
|
||||
tileStore.createIndex(urlTemplateIndex, 'urlTemplate');
|
||||
tileStore.createIndex('z', 'z');
|
||||
}
|
||||
},
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```js
|
||||
* import { getStorageLength } from 'leaflet.offline'
|
||||
* getStorageLength().then(i => console.log(i + 'tiles in storage'))
|
||||
* ```
|
||||
*/
|
||||
export async function getStorageLength(): Promise<number> {
|
||||
const db = await openTilesDataBase();
|
||||
return db.count(tileStoreName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```js
|
||||
* import { getStorageInfo } from 'leaflet.offline'
|
||||
* getStorageInfo('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')
|
||||
* ```
|
||||
*/
|
||||
export async function getStorageInfo(
|
||||
urlTemplate: string,
|
||||
): Promise<StoredTile[]> {
|
||||
const range = IDBKeyRange.only(urlTemplate);
|
||||
const db = await openTilesDataBase();
|
||||
return db.getAllFromIndex(tileStoreName, urlTemplateIndex, range);
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```js
|
||||
* import { downloadTile } from 'leaflet.offline'
|
||||
* downloadTile(tileInfo.url).then(blob => saveTile(tileInfo, blob))
|
||||
* ```
|
||||
*/
|
||||
export async function downloadTile(tileUrl: string): Promise<Blob> {
|
||||
const response = await fetch(tileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.statusText}`);
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
/**
|
||||
* @example
|
||||
* ```js
|
||||
* saveTile(tileInfo, blob).then(() => console.log(`saved tile from ${tileInfo.url}`))
|
||||
* ```
|
||||
*/
|
||||
export async function saveTile(
|
||||
tileInfo: TileInfo,
|
||||
blob: Blob,
|
||||
): Promise<IDBValidKey> {
|
||||
const db = await openTilesDataBase();
|
||||
return db.put(tileStoreName, {
|
||||
blob,
|
||||
...tileInfo,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTileUrl(urlTemplate: string, data: any): string {
|
||||
return Util.template(urlTemplate, {
|
||||
...data,
|
||||
r: Browser.retina ? '@2x' : '',
|
||||
});
|
||||
}
|
||||
|
||||
export function getTilePoints(area: Bounds, tileSize: Point): Point[] {
|
||||
const points: Point[] = [];
|
||||
if (!area.min || !area.max) {
|
||||
return points;
|
||||
}
|
||||
const topLeftTile = area.min.divideBy(tileSize.x).floor();
|
||||
const bottomRightTile = area.max.divideBy(tileSize.x).floor();
|
||||
|
||||
for (let j = topLeftTile.y; j <= bottomRightTile.y; j += 1) {
|
||||
for (let i = topLeftTile.x; i <= bottomRightTile.x; i += 1) {
|
||||
points.push(new Point(i, j));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
/**
|
||||
* Get a geojson of tiles from one resource
|
||||
*
|
||||
* @example
|
||||
* const urlTemplate = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
* const getGeoJsonData = () => LeafletOffline.getStorageInfo(urlTemplate)
|
||||
* .then((data) => LeafletOffline.getStoredTilesAsJson(baseLayer, data));
|
||||
*
|
||||
* getGeoJsonData().then((geojson) => {
|
||||
* storageLayer = L.geoJSON(geojson).bindPopup(
|
||||
* (clickedLayer) => clickedLayer.feature.properties.key,
|
||||
* );
|
||||
* });
|
||||
*
|
||||
*/
|
||||
export function getStoredTilesAsJson(
|
||||
tileSize: { x: number; y: number },
|
||||
tiles: TileInfo[],
|
||||
): FeatureCollection<Polygon> {
|
||||
const featureCollection: FeatureCollection<Polygon> = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
for (let i = 0; i < tiles.length; i += 1) {
|
||||
const topLeftPoint = new Point(
|
||||
tiles[i].x * tileSize.x,
|
||||
tiles[i].y * tileSize.y,
|
||||
);
|
||||
const bottomRightPoint = new Point(
|
||||
topLeftPoint.x + tileSize.x,
|
||||
topLeftPoint.y + tileSize.y,
|
||||
);
|
||||
|
||||
const topLeftlatlng = CRS.EPSG3857.pointToLatLng(topLeftPoint, tiles[i].z);
|
||||
const botRightlatlng = CRS.EPSG3857.pointToLatLng(
|
||||
bottomRightPoint,
|
||||
tiles[i].z,
|
||||
);
|
||||
featureCollection.features.push({
|
||||
type: 'Feature',
|
||||
properties: tiles[i],
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[topLeftlatlng.lng, topLeftlatlng.lat],
|
||||
[botRightlatlng.lng, topLeftlatlng.lat],
|
||||
[botRightlatlng.lng, botRightlatlng.lat],
|
||||
[topLeftlatlng.lng, botRightlatlng.lat],
|
||||
[topLeftlatlng.lng, topLeftlatlng.lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return featureCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tile by key
|
||||
*/
|
||||
export async function removeTile(key: string): Promise<void> {
|
||||
const db = await openTilesDataBase();
|
||||
return db.delete(tileStoreName, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single tile blob
|
||||
*/
|
||||
export async function getBlobByKey(key: string): Promise<Blob> {
|
||||
return (await openTilesDataBase())
|
||||
.get(tileStoreName, key)
|
||||
.then((result) => result && result.blob);
|
||||
}
|
||||
|
||||
export async function hasTile(key: string): Promise<boolean> {
|
||||
const db = await openTilesDataBase();
|
||||
const result = await db.getKey(tileStoreName, key);
|
||||
return result !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove everything
|
||||
*/
|
||||
export async function truncate(): Promise<void> {
|
||||
return (await openTilesDataBase()).clear(tileStoreName);
|
||||
}
|
||||
|
||||
export async function getTileImageSource(key: string, url: string) {
|
||||
const shouldUseUrl = !(await hasTile(key));
|
||||
if (shouldUseUrl) {
|
||||
return url;
|
||||
}
|
||||
const blob = await getBlobByKey(key);
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
BIN
pwa/node_modules/leaflet.offline/src/images/save.png
generated
vendored
Normal file
BIN
pwa/node_modules/leaflet.offline/src/images/save.png
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 596 B |
23
pwa/node_modules/leaflet.offline/src/index.ts
generated
vendored
Normal file
23
pwa/node_modules/leaflet.offline/src/index.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export { tileLayerOffline } from './TileLayerOffline';
|
||||
export { savetiles } from './ControlSaveTiles';
|
||||
export type {
|
||||
SaveStatus,
|
||||
ControlSaveTiles,
|
||||
SaveTileOptions,
|
||||
} from './ControlSaveTiles';
|
||||
export type { TileInfo, StoredTile } from './TileManager';
|
||||
export type { TileLayerOffline } from './TileLayerOffline';
|
||||
export {
|
||||
getStorageInfo,
|
||||
getStorageLength,
|
||||
getStoredTilesAsJson,
|
||||
removeTile,
|
||||
truncate,
|
||||
downloadTile,
|
||||
saveTile,
|
||||
hasTile,
|
||||
getBlobByKey,
|
||||
getTilePoints,
|
||||
getTileUrl,
|
||||
getTileImageSource,
|
||||
} from './TileManager';
|
||||
Loading…
Add table
Add a link
Reference in a new issue