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,143 @@
import { Map } from 'leaflet';
import { assert } from 'chai';
import { ControlSaveTiles, savetiles } from '../src/ControlSaveTiles';
import { TileLayerOffline } from '../src/TileLayerOffline';
import * as sinon from 'sinon';
describe('control with defaults', () => {
let saveControl: ControlSaveTiles;
let baseLayer: TileLayerOffline;
beforeEach(() => {
const leafletMap = new Map(document.createElement('div'));
leafletMap.setView(
{
lat: 51.985,
lng: 5,
},
16,
);
baseLayer = new TileLayerOffline(
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{
subdomains: 'abc',
},
).addTo(leafletMap);
saveControl = savetiles(baseLayer, {});
saveControl.addTo(leafletMap);
saveControl._rmTiles();
});
it('exists', () => {
assert.ok(savetiles);
});
it('adds button', () => {
const div = saveControl.onAdd();
assert.ok(div);
assert.lengthOf(div.querySelectorAll('a'), 2);
});
it('calculates storagesize', () =>
saveControl.setStorageSize().then((n) => {
assert.equal(n, 0);
}));
it('_saveTiles sets status', () => {
const stub = sinon
.stub(saveControl, '_loadTile')
.returns(Promise.resolve(new Blob()));
const resetstub = sinon.stub(saveControl, '_resetStatus');
saveControl._saveTiles();
assert.isObject(saveControl.status);
assert.isTrue(resetstub.calledOnce);
stub.resetBehavior();
resetstub.resetBehavior();
});
it('_saveTiles fires savestart with _tilesforSave prop', (done) => {
const stub = sinon
.stub(saveControl, '_loadTile')
.returns(Promise.resolve(new Blob()));
baseLayer.on('savestart', (status) => {
// TODO
// @ts-ignore
assert.lengthOf(status._tilesforSave, 1);
stub.resetBehavior();
done();
});
saveControl._saveTiles();
});
it('_saveTiles calls loadTile for each tile', () => {
const stub = sinon
.stub(saveControl, '_loadTile')
.returns(Promise.resolve(new Blob()));
saveControl._saveTiles();
assert.equal(
stub.callCount,
1,
`_loadTile has been called ${stub.callCount} times`,
);
stub.resetBehavior();
});
});
describe('control with different options', () => {
let leafletMap: Map;
let baseLayer: TileLayerOffline;
beforeEach(() => {
leafletMap = new Map(document.createElement('div'));
leafletMap.setView(
{
lat: 51.985,
lng: 5,
},
16,
);
baseLayer = new TileLayerOffline(
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{
subdomains: 'abc',
},
).addTo(leafletMap);
});
it('_saveTiles calculates tiles for 2 zoomlevels', () => {
const c = savetiles(baseLayer, {
zoomlevels: [16, 17],
});
c.addTo(leafletMap);
c._rmTiles();
const stub = sinon
.stub(c, '_loadTile')
.returns(Promise.resolve(new Blob()));
c._saveTiles();
assert.isObject(c.status);
assert.isArray(c.status._tilesforSave);
assert.isAbove(stub.callCount, 1);
stub.resetBehavior();
});
it('_saveTiles calcs tiles for saveWhatYouSee', () => {
const c = savetiles(baseLayer, {
saveWhatYouSee: true,
});
c.addTo(leafletMap);
c._rmTiles();
const stub = sinon
.stub(c, '_loadTile')
.returns(Promise.resolve(new Blob()));
c._saveTiles();
assert.isObject(c.status);
assert.isArray(c.status._tilesforSave);
assert.equal(
stub.callCount,
4,
`_loadTile has been called ${stub.callCount} times`,
);
stub.resetBehavior();
});
it('calls confirm', () => {
const callback = sinon.spy();
const c = savetiles(baseLayer, {
confirm: callback,
});
c.addTo(leafletMap);
c._rmTiles();
c._saveTiles();
assert(callback.calledOnce);
});
});

136
pwa/node_modules/leaflet.offline/test/TileLayerTest.ts generated vendored Normal file
View file

@ -0,0 +1,136 @@
import { Bounds, Point } from 'leaflet';
import { assert } from 'chai';
import { TileLayerOffline } from '../src/TileLayerOffline';
describe('TileLayer.Offline', () => {
it('createTile', () => {
const url = 'http://a.tile.openstreetmap.org/{z}/{x}/{y}.png';
const layer = new TileLayerOffline(url);
// @ts-ignore
const tile = layer.createTile({ x: 123456, y: 456789, z: 16 }, () => {});
assert.instanceOf(tile, HTMLElement);
});
it('get storagekey openstreetmap', () => {
const layer = new TileLayerOffline(
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
);
const key = layer._getStorageKey({ z: 16, x: 123456, y: 456789 });
assert.equal(key, 'http://a.tile.openstreetmap.org/16/123456/456789.png');
});
it('get storagekey cartodb', () => {
const layer = new TileLayerOffline(
'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
);
const key = layer._getStorageKey({ z: 16, x: 123456, y: 456789 });
assert.equal(
key,
'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/16/123456/456789.png',
);
});
it('get storagekey mapbox with accessToken', () => {
const layer = new TileLayerOffline(
'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}',
{
id: 'mapbox.streets',
accessToken: 'xyz',
},
);
const key = layer._getStorageKey({ z: 16, x: 123456, y: 456789 });
assert.equal(
key,
'https://api.tiles.mapbox.com/v4/mapbox.streets/16/123456/456789.png?access_token=xyz',
);
});
it('calculates tiles at level 16', () => {
const layer = new TileLayerOffline(
'http://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
);
const bounds = new Bounds(
new Point(8621975, 5543267.999999999),
new Point(8621275, 5542538),
);
const tiles = layer.getTileUrls(bounds, 16);
assert.lengthOf(tiles, 16);
const urls = tiles.map((t) => t.url);
assert.include(urls, 'http://a.tile.openstreetmap.org/16/33677/21651.png');
const keys = tiles.map((t) => t.key);
assert.include(keys, 'http://a.tile.openstreetmap.org/16/33677/21651.png');
});
it('calculates tile urls,keys at level 16 with subdomains', () => {
const layer = new TileLayerOffline(
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
);
const bounds = new Bounds(
new Point(8621975, 5543267.999999999),
new Point(8621275, 5542538),
);
const tiles = layer.getTileUrls(bounds, 16);
assert.lengthOf(tiles, 16);
const urls = tiles.map((t) => t.url.replace(/[abc]\./, ''));
assert.include(urls, 'http://tile.openstreetmap.org/16/33677/21651.png');
const keys = tiles.map((t) => t.key);
assert.include(keys, 'http://a.tile.openstreetmap.org/16/33677/21651.png');
});
it('uses subdomains for url and not for key', () => {
const layer = new TileLayerOffline(
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
);
const bounds = new Bounds(
new Point(8621975, 5543267.999999999),
new Point(8621275, 5542538),
);
const tiles = layer.getTileUrls(bounds, 16);
const subs = tiles.map((t) => t.url.match(/([abc])\./)?.[1]);
assert.include(subs, 'a');
assert.include(subs, 'b');
assert.include(subs, 'c');
const subskeys = tiles.map((t) => t.key.match(/([abc])\./)?.[1]);
assert.include(subskeys, 'a');
assert.notInclude(subskeys, 'b');
assert.notInclude(subskeys, 'c');
});
it('calculates openstreetmap tiles at level 16', () => {
const layer = new TileLayerOffline(
'http://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
);
const bounds = new Bounds(
new Point(8621975, 5543267.999999999),
new Point(8621275, 5542538),
);
const tiles = layer.getTileUrls(bounds, 16);
assert.lengthOf(tiles, 16);
const urls = tiles.map((t) => t.url);
assert.include(urls, 'http://a.tile.openstreetmap.org/16/33677/21651.png');
const keys = tiles.map((t) => t.key);
assert.include(keys, 'http://a.tile.openstreetmap.org/16/33677/21651.png');
});
it('calculates mobox tiles at level 16', () => {
const layer = new TileLayerOffline(
'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}',
{
id: 'mapbox.streets',
accessToken: 'xyz',
},
);
const bounds = new Bounds(
new Point(8621975, 5543267.999999999),
new Point(8621275, 5542538),
);
const tiles = layer.getTileUrls(bounds, 16);
assert.lengthOf(tiles, 16);
const urls = tiles.map((t) => t.url);
assert.include(
urls,
'https://api.tiles.mapbox.com/v4/mapbox.streets/16/33677/21651.png?access_token=xyz',
);
const keys = tiles.map((t) => t.key);
assert.include(
keys,
'https://api.tiles.mapbox.com/v4/mapbox.streets/16/33677/21651.png?access_token=xyz',
);
});
});

View file

@ -0,0 +1,140 @@
/* global describe, it, beforeEach */
import { point, bounds, gridLayer } from 'leaflet';
import fetchMock from 'fetch-mock';
import { assert } from 'chai';
import {
downloadTile,
getStorageInfo,
getStorageLength,
getStoredTilesAsJson,
getTileImageSource,
getTilePoints,
hasTile,
removeTile,
saveTile,
truncate,
} from '../src/TileManager';
const testTileInfo = {
url: 'https://api.tiles.mapbox.com/v4/mapbox.streets/16/33677/21651.png?access_token=xyz',
key: 'https://api.tiles.mapbox.com/v4/mapbox.streets/16/33677/21651.png?access_token=xyz',
x: 33677,
y: 21651,
z: 16,
urlTemplate:
'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}',
createdAt: Date.now(),
};
describe('manage tile storage', () => {
before(() => {
fetchMock.mockGlobal();
});
after(() => {
fetchMock.unmockGlobal();
});
beforeEach(() => truncate());
it('saves a tile', () =>
saveTile(testTileInfo, new Blob()).then((r) => {
assert.equal(
r,
'https://api.tiles.mapbox.com/v4/mapbox.streets/16/33677/21651.png?access_token=xyz',
);
}));
it('will return empty storageinfo when no tiles are stored', async () => {
const info = await getStorageInfo(testTileInfo.urlTemplate);
assert.lengthOf(info, 0);
});
it('will return storageinfo with single saved tile', async () => {
await saveTile(testTileInfo, new Blob());
const info = await getStorageInfo(testTileInfo.urlTemplate);
assert.lengthOf(info, 1);
const { blob, ...expectedInfo } = info[0];
assert.deepEqual(expectedInfo, testTileInfo);
});
it('will return empty storageinfo for other url template', async () => {
await saveTile(testTileInfo, new Blob());
const info = await getStorageInfo(
'http://someotherexample/{z}/{x}/{y}.png',
);
assert.lengthOf(info, 0);
});
it('will return length 0 on an empty db', async () => {
const length = await getStorageLength();
assert.equal(length, 0);
});
it('will calc tile points', () => {
const minBound = point(0, 0);
const maxBound = point(200, 200);
const tilebounds = bounds(minBound, maxBound);
const tilePoints = getTilePoints(tilebounds, point(256, 256));
assert.lengthOf(tilePoints, 1);
});
it('has tile finds tile by key', async () => {
await saveTile(testTileInfo, new Blob());
const result = await hasTile(testTileInfo.key);
assert.isTrue(result);
});
it('deletes tile finds tile by key', async () => {
await saveTile(testTileInfo, new Blob());
await removeTile(testTileInfo.key);
const result = await hasTile(testTileInfo.key);
assert.isFalse(result);
});
it('Creates geojson with tiles', () => {
const layer = gridLayer();
const json = getStoredTilesAsJson(layer.getTileSize(), [testTileInfo]);
assert.lengthOf(json.features, 1);
const feature = json.features[0];
assert.equal(feature.type, 'Feature');
assert.equal(feature.geometry.type, 'Polygon');
assert.lengthOf(feature.geometry.coordinates, 1);
assert.lengthOf(feature.geometry.coordinates[0], 5);
});
it('downloads a tile', async () => {
const url = 'https://tile.openstreetmap.org/16/33700/21621.png';
fetchMock.once(url, new Blob());
const result = await downloadTile(url);
assert.instanceOf(result, Blob);
fetchMock.removeRoutes();
});
it('downloading a tile throws if response is not successful', async () => {
const url = 'https://tile.openstreetmap.org/16/33700/21621.png';
let err;
fetchMock.once(url, 400);
try {
await downloadTile(url);
} catch (error) {
err = error;
}
assert.instanceOf(err, Error);
fetchMock.removeRoutes();
});
it('get image src returns url if tile with key does not exist', async () => {
const result = await getTileImageSource(testTileInfo.key, testTileInfo.url);
assert.equal(result, testTileInfo.url);
});
it('get image src returns dataSource url if tile key does exist', async () => {
await saveTile(testTileInfo, new Blob());
const result = await getTileImageSource(
testTileInfo.key,
'http://someurl/tile.png',
);
assert.isString(result);
assert.isTrue(result.includes('blob:'));
});
});