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:
Ole-Morten Duesund 2026-03-08 17:41:38 +01:00
commit e8428de775
12051 changed files with 1799735 additions and 0 deletions

View 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;
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

23
pwa/node_modules/leaflet.offline/src/index.ts generated vendored Normal file
View 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';