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
143
pwa/node_modules/leaflet.offline/test/ControlSaveTilesTest.ts
generated
vendored
Normal file
143
pwa/node_modules/leaflet.offline/test/ControlSaveTilesTest.ts
generated
vendored
Normal 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
136
pwa/node_modules/leaflet.offline/test/TileLayerTest.ts
generated
vendored
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
140
pwa/node_modules/leaflet.offline/test/TileManagerTest.ts
generated
vendored
Normal file
140
pwa/node_modules/leaflet.offline/test/TileManagerTest.ts
generated
vendored
Normal 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:'));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue