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,219 @@
/**
* Build-time script: downloads the Geonorge shelter ZIP, extracts GeoJSON,
* converts UTM33N (EPSG:25833) WGS84 (EPSG:4326), and outputs shelters.json.
*
* Run: bunx tsx scripts/fetch-shelters.ts
*
* Coordinate conversion ported from CoordinateConverter.kt (Karney method).
* GeoJSON parsing ported from ShelterGeoJsonParser.kt.
*/
import { mkdirSync, existsSync } from 'fs';
import { writeFile } from 'fs/promises';
import { join, dirname } from 'path';
import { inflateRawSync } from 'zlib';
const DOWNLOAD_URL =
'https://nedlasting.geonorge.no/geonorge/Samfunnssikkerhet/TilfluktsromOffentlige/GeoJSON/Samfunnssikkerhet_0000_Norge_25833_TilfluktsromOffentlige_GeoJSON.zip';
const OUTPUT_PATH = join(dirname(import.meta.url.replace('file://', '')), '..', 'public', 'data', 'shelters.json');
// --- UTM33N → WGS84 conversion (Karney series expansion) ---
const A = 6378137.0; // WGS84 semi-major axis (m)
const F = 1.0 / 298.257223563; // flattening
const E2 = 2 * F - F * F; // eccentricity squared
const K0 = 0.9996; // UTM scale factor
const FALSE_EASTING = 500000.0;
const ZONE_33_CENTRAL_MERIDIAN = 15.0; // degrees
interface LatLon {
latitude: number;
longitude: number;
}
function utm33nToWgs84(easting: number, northing: number): LatLon {
const x = easting - FALSE_EASTING;
const y = northing;
const e1 = (1 - Math.sqrt(1 - E2)) / (1 + Math.sqrt(1 - E2));
const m = y / K0;
const mu = m / (A * (1 - E2 / 4 - (3 * E2 * E2) / 64 - (5 * E2 ** 3) / 256));
// Footprint latitude via series expansion
const phi1 =
mu +
(3 * e1 / 2 - 27 * e1 ** 3 / 32) * Math.sin(2 * mu) +
(21 * e1 ** 2 / 16 - 55 * e1 ** 4 / 32) * Math.sin(4 * mu) +
(151 * e1 ** 3 / 96) * Math.sin(6 * mu) +
(1097 * e1 ** 4 / 512) * Math.sin(8 * mu);
const sinPhi1 = Math.sin(phi1);
const cosPhi1 = Math.cos(phi1);
const tanPhi1 = Math.tan(phi1);
const n1 = A / Math.sqrt(1 - E2 * sinPhi1 * sinPhi1);
const t1 = tanPhi1 * tanPhi1;
const c1 = (E2 / (1 - E2)) * cosPhi1 * cosPhi1;
const r1 = (A * (1 - E2)) / (1 - E2 * sinPhi1 * sinPhi1) ** 1.5;
const d = x / (n1 * K0);
const ep2 = E2 / (1 - E2);
const lat =
phi1 -
(n1 * tanPhi1 / r1) *
(d ** 2 / 2 -
(5 + 3 * t1 + 10 * c1 - 4 * c1 * c1 - 9 * ep2) * d ** 4 / 24 +
(61 + 90 * t1 + 298 * c1 + 45 * t1 * t1 - 252 * ep2 - 3 * c1 * c1) * d ** 6 / 720);
const lon =
(d -
(1 + 2 * t1 + c1) * d ** 3 / 6 +
(5 - 2 * c1 + 28 * t1 - 3 * c1 * c1 + 8 * ep2 + 24 * t1 * t1) * d ** 5 / 120) /
cosPhi1;
return {
latitude: (lat * 180) / Math.PI,
longitude: ZONE_33_CENTRAL_MERIDIAN + (lon * 180) / Math.PI,
};
}
// --- ZIP extraction + GeoJSON parsing ---
interface GeoJsonFeature {
geometry: { coordinates: number[] };
properties: {
lokalId?: string;
romnr?: number;
plasser?: number;
adresse?: string;
};
}
interface GeoJsonRoot {
features: GeoJsonFeature[];
}
interface Shelter {
lokalId: string;
romnr: number;
plasser: number;
adresse: string;
latitude: number;
longitude: number;
}
async function downloadAndExtractZip(url: string): Promise<string> {
console.log(`Downloading ${url}...`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
return extractGeoJsonFromZipBuffer(buffer);
}
function extractGeoJsonFromZipBuffer(zipBuffer: Buffer): string {
// ZIP local file header signature: PK\x03\x04
// We need to find and extract the .geojson/.json file.
// Since Node has no built-in ZIP reader, let's parse the central directory.
let offset = 0;
const files: { name: string; compressedData: Buffer; method: number; uncompressedSize: number }[] = [];
while (offset < zipBuffer.length - 4) {
const sig = zipBuffer.readUInt32LE(offset);
if (sig !== 0x04034b50) break; // Not a local file header
const method = zipBuffer.readUInt16LE(offset + 8);
const compressedSize = zipBuffer.readUInt32LE(offset + 18);
const uncompressedSize = zipBuffer.readUInt32LE(offset + 22);
const nameLen = zipBuffer.readUInt16LE(offset + 26);
const extraLen = zipBuffer.readUInt16LE(offset + 28);
const name = zipBuffer.subarray(offset + 30, offset + 30 + nameLen).toString('utf8');
const dataStart = offset + 30 + nameLen + extraLen;
const compressedData = zipBuffer.subarray(dataStart, dataStart + compressedSize);
files.push({ name, compressedData, method, uncompressedSize });
offset = dataStart + compressedSize;
}
const geoFile = files.find(
(f) => f.name.endsWith('.geojson') || f.name.endsWith('.json'),
);
if (!geoFile) {
throw new Error('No GeoJSON file found in ZIP archive');
}
console.log(`Extracting ${geoFile.name} (${geoFile.compressedData.length} bytes compressed)...`);
if (geoFile.method === 0) {
// Stored (no compression)
return geoFile.compressedData.toString('utf8');
} else if (geoFile.method === 8) {
// Deflated
const inflated = inflateRawSync(geoFile.compressedData);
return inflated.toString('utf8');
} else {
throw new Error(`Unsupported compression method: ${geoFile.method}`);
}
}
function parseGeoJson(json: string): Shelter[] {
const root: GeoJsonRoot = JSON.parse(json);
const shelters: Shelter[] = [];
for (let i = 0; i < root.features.length; i++) {
const feature = root.features[i];
const coords = feature.geometry.coordinates;
const props = feature.properties;
const easting = coords[0];
const northing = coords[1];
const latLon = utm33nToWgs84(easting, northing);
shelters.push({
lokalId: props.lokalId ?? `unknown-${i}`,
romnr: props.romnr ?? 0,
plasser: props.plasser ?? 0,
adresse: props.adresse ?? '',
latitude: Math.round(latLon.latitude * 1e6) / 1e6,
longitude: Math.round(latLon.longitude * 1e6) / 1e6,
});
}
return shelters;
}
// --- Main ---
async function main() {
const outputDir = dirname(OUTPUT_PATH);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
const geoJson = await downloadAndExtractZip(DOWNLOAD_URL);
const shelters = parseGeoJson(geoJson);
console.log(`Parsed ${shelters.length} shelters`);
// Validate: check a few coordinates are in Norway's range
const invalid = shelters.filter(
(s) => s.latitude < 57 || s.latitude > 72 || s.longitude < 4 || s.longitude > 32,
);
if (invalid.length > 0) {
console.warn(`WARNING: ${invalid.length} shelters have coordinates outside Norway's range`);
}
await writeFile(OUTPUT_PATH, JSON.stringify(shelters, null, 2), 'utf8');
console.log(`Wrote ${OUTPUT_PATH} (${shelters.length} shelters)`);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});