Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"date-fns": "^4.4.0",
"i18next": "^26.3.4",
"maplibre-gl": "^5.24.0",
"pmtiles": "^4.4.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
Expand All @@ -34,6 +35,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@eslint/js": "^10.0.1",
"@mapbox/vector-tile": "^3.0.0",
"@playwright/test": "^1.61.1",
"@tanstack/router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.19",
Expand All @@ -51,6 +53,7 @@
"eslint-config-prettier": "^10.1.8",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"pbf": "^5.1.2",
"prettier": "^3.9.4",
"typescript": "~6.0.2",
"vite": "^8.1.1",
Expand Down
Binary file not shown.
77 changes: 77 additions & 0 deletions apps/client/src/lib/__tests__/pmtilesFixture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { PMTiles, type Source, type RangeResponse } from 'pmtiles';
import fs from 'node:fs';
import path from 'node:path';

const FIXTURE = path.resolve(__dirname, '../../../public/fixtures/r3a-countries.pmtiles');

/** Serves the fixture from disk the way the browser serves it over range requests. */
class LocalFileSource implements Source {
private readonly buffer: Buffer;

constructor(file: string) {
this.buffer = fs.readFileSync(file);
}

getKey(): string {
return FIXTURE;
}

async getBytes(offset: number, length: number): Promise<RangeResponse> {
const slice = this.buffer.subarray(offset, offset + length);
return {
data: slice.buffer.slice(
slice.byteOffset,
slice.byteOffset + slice.byteLength
) as ArrayBuffer,
};
}
}

/**
* The archive is written by packages/core/scripts/build-pmtiles-fixture.mjs, which
* implements the PMTiles v3 container directly. These assertions are what stop a
* malformed archive from reaching the spike, where it would look like a Tauri problem.
*/
describe('R3-A offline map fixture', () => {
const archive = new PMTiles(new LocalFileSource(FIXTURE));

it('is a valid PMTiles v3 archive of vector tiles', async () => {
const header = await archive.getHeader();

expect(header.tileType).toBe(1); // MVT
expect(header.minZoom).toBe(0);
expect(header.maxZoom).toBe(4);
expect(header.numAddressedTiles).toBeGreaterThan(0);
// Identical ocean tiles are deduplicated, so contents must not exceed entries.
expect(header.numTileContents).toBeLessThanOrEqual(header.numAddressedTiles);
});

it('declares the layer the probe style renders', async () => {
const metadata = (await archive.getMetadata()) as {
vector_layers: { id: string }[];
attribution: string;
};

expect(metadata.vector_layers.map((layer) => layer.id)).toContain('countries');
// Redistributable by design: the spike must not embed OSM-derived basemap data.
expect(metadata.attribution).toContain('Natural Earth');
});

it('returns tile data at every zoom level the style may request', async () => {
for (let z = 0; z <= 4; z++) {
const tile = await archive.getZxy(z, 0, 0);
// z/0/0 is ocean at some zooms; what matters is that lookups resolve without error.
expect(tile === undefined || tile.data.byteLength > 0).toBe(true);
}

// A tile that certainly covers land (North America at z2).
const populated = await archive.getZxy(2, 1, 1);
expect(populated?.data.byteLength).toBeGreaterThan(0);
});

it('stays small enough to ship inside the app bundle', () => {
const sizeKiB = fs.statSync(FIXTURE).size / 1024;
expect(sizeKiB).toBeLessThan(1024);
});
});
173 changes: 173 additions & 0 deletions apps/client/src/lib/offlineMapProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { OfflineMapProbeResult } from '@hap/core';

/**
* R3-A offline map proof.
*
* Renders MapLibre from a PMTiles archive bundled with the app and records every
* resource the map asks for, so the run shows that nothing but the local archive is
* needed. The production map still uses OSM raster tiles over the network; this probe
* deliberately does not, because the spike has to show the map standing up with no
* connectivity at all.
*/

const FIXTURE_URL = '/fixtures/r3a-countries.pmtiles';

function isLocalFixture(url: string): boolean {
// `pmtiles://…` is resolved by the registered protocol against the local archive
// and never reaches the network, so it is always allowed. Everything else must be
// the bundled fixture served by the app itself.
if (url.startsWith('pmtiles://')) return true;

try {
const resolved = new URL(url, window.location.href);
return resolved.origin === window.location.origin && resolved.pathname === FIXTURE_URL;
} catch {
return false;
}
}

/**
* Under Tauri the archive is read from the bundled resource; in a browser it is
* fetched from the public directory. Returns the identifier the style must use.
*/
async function registerArchive(protocol: {
add: (archive: unknown) => void;
}): Promise<{ sourceUrl: string; transport: 'tauri-fs' | 'http' }> {
const { isTauri } = await import('@tauri-apps/api/core');

if (!isTauri()) {
return {
sourceUrl: `pmtiles://${new URL(FIXTURE_URL, window.location.href).href}`,
transport: 'http',
};
}

const [{ resolveResource }, { PMTiles }, { TauriFileSource }] = await Promise.all([
import('@tauri-apps/api/path'),
import('pmtiles'),
import('./tauriPmtilesSource'),
]);

const path = await resolveResource('fixtures/r3a-countries.pmtiles');
const archive = new PMTiles(new TauriFileSource(path));
protocol.add(archive);

return { sourceUrl: `pmtiles://${path}`, transport: 'tauri-fs' };
}

/**
* The map from the previous run, kept so a human can still see the proof after the
* numbers are in. Torn down when the probe runs again.
*/
let liveMap: { remove: () => void } | null = null;

export async function runOfflineMapProbe(container: HTMLElement): Promise<OfflineMapProbeResult> {
liveMap?.remove();
liveMap = null;

const [{ default: maplibregl }, { Protocol }] = await Promise.all([
import('maplibre-gl'),
import('pmtiles'),
]);

const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);

const { sourceUrl, transport } = await registerArchive(
protocol as unknown as { add: (archive: unknown) => void }
);

// Every resource MapLibre resolves passes through here. Patching window.fetch
// instead would also intercept the framework's own module and worker loading and
// break the map before it starts.
const blockedRequests: string[] = [];

try {
const map = new maplibregl.Map({
container,
// No glyphs and no sprite: text or icon layers would need assets the archive
// does not carry, which is the sort of hidden network dependency this proves out.
style: {
version: 8,
sources: {
countries: { type: 'vector', url: sourceUrl },
},
layers: [
{ id: 'background', type: 'background', paint: { 'background-color': '#dbeafe' } },
{
id: 'countries-fill',
type: 'fill',
source: 'countries',
'source-layer': 'countries',
paint: { 'fill-color': '#bbf7d0', 'fill-outline-color': '#15803d' },
},
],
},
center: [-73.5673, 45.5017],
zoom: 3,
attributionControl: false,
transformRequest: (url: string) => {
if (isLocalFixture(url)) return { url };
// Anything else would be a network dependency: record it and refuse it by
// handing MapLibre a URL it cannot resolve.
blockedRequests.push(url);
return { url: 'about:blank' };
},
});

// Step 1 — the archive is read and decoded. This is the part that proves the map
// works offline, and it does not depend on the host painting anything.
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(
() => reject(new Error('Source did not load from the bundled archive within 20s')),
20000
);
const onSourceData = (event: { sourceId?: string; isSourceLoaded?: boolean }) => {
if (event.sourceId === 'countries' && event.isSourceLoaded) {
window.clearTimeout(timeout);
map.off('sourcedata', onSourceData);
resolve();
}
};
map.on('sourcedata', onSourceData);
map.once('error', (event: { error?: Error }) => {
window.clearTimeout(timeout);
reject(event.error ?? new Error('MapLibre reported an error'));
});
});

const sourceFeatures = map.querySourceFeatures('countries', {
sourceLayer: 'countries',
}).length;

// Step 2 — painting. A hidden or headless webview throttles requestAnimationFrame,
// so treat a missing frame as "not observable here" rather than as a failure.
const painted = await new Promise<boolean>((resolve) => {
const timeout = window.setTimeout(() => resolve(false), 5000);
map.once('idle', () => {
window.clearTimeout(timeout);
resolve(true);
});
});

const renderedFeatures = painted
? map.queryRenderedFeatures(undefined, { layers: ['countries-fill'] }).length
: null;

// Left on screen on purpose: destroying it here is what made the map flash and
// vanish the moment the probe succeeded.
liveMap = map;

return {
sourceFeatures,
renderedFeatures,
blockedRequests: [...blockedRequests],
fixtureUrl: sourceUrl,
transport,
};
} catch (error) {
// Only tear down when the run failed; a successful map stays visible.
maplibregl.removeProtocol('pmtiles');
throw error;
}
}
70 changes: 70 additions & 0 deletions apps/client/src/lib/tauriPmtilesSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { RangeResponse, Source } from 'pmtiles';

/**
* A PMTiles source that reads byte ranges straight off the local filesystem.
*
* The webview cannot serve the archive over HTTP byte ranges: `tauri://localhost`
* answers a `Range` request with the whole file and a 200, which the PMTiles reader
* rejects outright ("Check that your storage backend supports HTTP Byte Serving").
* Reading the bundled resource natively sidesteps the webview entirely, which is
* closer to what a local-first desktop build would do anyway.
*/
export class TauriFileSource implements Source {
private readonly path: string;
private handle: Awaited<ReturnType<typeof import('@tauri-apps/plugin-fs').open>> | null = null;
/**
* A seek followed by a read is two round trips over one shared cursor, and MapLibre
* asks for many tiles at once. Without this queue the seeks interleave and every
* reader gets bytes meant for another range — which surfaces far away as a corrupt
* tile ("Extra bytes past the end").
*/
private queue: Promise<unknown> = Promise.resolve();

constructor(path: string) {
this.path = path;
}

private serialize<T>(work: () => Promise<T>): Promise<T> {
const result = this.queue.then(work, work);
this.queue = result.catch(() => undefined);
return result;
}

getKey(): string {
return this.path;
}

private async getHandle() {
if (!this.handle) {
const { open } = await import('@tauri-apps/plugin-fs');
this.handle = await open(this.path, { read: true });
}
return this.handle;
}

async getBytes(offset: number, length: number): Promise<RangeResponse> {
return this.serialize(() => this.readRange(offset, length));
}

private async readRange(offset: number, length: number): Promise<RangeResponse> {
const { SeekMode } = await import('@tauri-apps/plugin-fs');
const file = await this.getHandle();

await file.seek(offset, SeekMode.Start);

// A single read may return fewer bytes than asked for; keep going until the
// range is filled or the file ends.
const buffer = new Uint8Array(length);
let filled = 0;
while (filled < length) {
const chunk = new Uint8Array(length - filled);
const read = await file.read(chunk);
if (read === null || read === 0) break;
buffer.set(chunk.subarray(0, read), filled);
filled += read;
}

const data = buffer.subarray(0, filled);
return { data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) };
}
}
Loading
Loading