diff --git a/apps/client/package.json b/apps/client/package.json index fe2dd0d..bfc00c6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", @@ -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", @@ -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", diff --git a/apps/client/public/fixtures/r3a-countries.pmtiles b/apps/client/public/fixtures/r3a-countries.pmtiles new file mode 100644 index 0000000..30eb4fa Binary files /dev/null and b/apps/client/public/fixtures/r3a-countries.pmtiles differ diff --git a/apps/client/src/lib/__tests__/pmtilesFixture.test.ts b/apps/client/src/lib/__tests__/pmtilesFixture.test.ts new file mode 100644 index 0000000..5745908 --- /dev/null +++ b/apps/client/src/lib/__tests__/pmtilesFixture.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/client/src/lib/offlineMapProbe.ts b/apps/client/src/lib/offlineMapProbe.ts new file mode 100644 index 0000000..379fada --- /dev/null +++ b/apps/client/src/lib/offlineMapProbe.ts @@ -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 { + 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((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((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; + } +} diff --git a/apps/client/src/lib/tauriPmtilesSource.ts b/apps/client/src/lib/tauriPmtilesSource.ts new file mode 100644 index 0000000..1341129 --- /dev/null +++ b/apps/client/src/lib/tauriPmtilesSource.ts @@ -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> | 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 = Promise.resolve(); + + constructor(path: string) { + this.path = path; + } + + private serialize(work: () => Promise): Promise { + 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 { + return this.serialize(() => this.readRange(offset, length)); + } + + private async readRange(offset: number, length: number): Promise { + 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) }; + } +} diff --git a/apps/client/src/lib/tauriSpikeProbe.ts b/apps/client/src/lib/tauriSpikeProbe.ts index 47cbbc4..6580ab2 100644 --- a/apps/client/src/lib/tauriSpikeProbe.ts +++ b/apps/client/src/lib/tauriSpikeProbe.ts @@ -3,7 +3,12 @@ import { join } from '@tauri-apps/api/path'; import { open } from '@tauri-apps/plugin-dialog'; import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; import Database from '@tauri-apps/plugin-sql'; -import type { LocalPlatformProbe, SqliteProbeResult, VaultProbeResult } from '@hap/core'; +import type { + LocalPlatformProbe, + SqliteProbeResult, + VaultCapabilityResult, + VaultProbeResult, +} from '@hap/core'; const SPIKE_VALUE = 'hap-r3-a-sqlite-persistence'; const SPIKE_FILE = 'hap-r3-a-vault-probe.md'; @@ -41,6 +46,69 @@ export const tauriSpikeProbe: LocalPlatformProbe = { } }, + async recordProbeVerdict(probe: string, verdict: unknown): Promise { + const database = await Database.load('sqlite:hap-r3-a-spike.sqlite'); + try { + await database.execute( + 'CREATE TABLE IF NOT EXISTS probe_verdicts (probe TEXT PRIMARY KEY, verdict TEXT NOT NULL, recorded_at TEXT NOT NULL)' + ); + await database.execute( + 'INSERT OR REPLACE INTO probe_verdicts (probe, verdict, recorded_at) VALUES ($1, $2, $3)', + [probe, JSON.stringify(verdict), new Date().toISOString()] + ); + } finally { + await database.close(); + } + }, + + /** + * Runs without interaction, so the mobile targets can be checked by a smoke script. + * + * The desktop vault hands the user an arbitrary folder. Mobile may not allow that at + * all, and the spec requires recording the constraint rather than pretending desktop + * semantics carry over — so this reports both what app-scoped storage can do and + * whether a directory picker exists. + */ + async probeVaultCapability(): Promise { + let appScopedWrite: VaultCapabilityResult['appScopedWrite'] = null; + const notes: string[] = []; + + try { + const { writeTextFile, readTextFile, BaseDirectory } = await import('@tauri-apps/plugin-fs'); + const contents = '# HAP R3-A vault capability\n\nEcriture UTF-8 en stockage applicatif.\n'; + await writeTextFile(SPIKE_FILE, contents, { baseDir: BaseDirectory.AppData }); + const readBack = await readTextFile(SPIKE_FILE, { baseDir: BaseDirectory.AppData }); + appScopedWrite = { path: `AppData/${SPIKE_FILE}`, roundTrip: readBack === contents }; + } catch (error) { + notes.push(`app-scoped write failed: ${error instanceof Error ? error.message : error}`); + } + + // A directory picker that is missing usually rejects immediately; one that exists + // would block on UI, which a smoke run must not do. Time-box it either way. + // Both branches below assign it, so an initial value would be dead. + let directoryPicker: VaultCapabilityResult['directoryPicker']; + try { + const picked = await Promise.race([ + open({ directory: true, multiple: false, title: 'Capability probe' }), + new Promise((_, reject) => setTimeout(() => reject(new Error('__ui_shown__')), 2500)), + ]); + directoryPicker = 'supported'; + notes.push(`picker returned ${JSON.stringify(picked)}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === '__ui_shown__') { + // It opened and is waiting for a human: the capability exists. + directoryPicker = 'supported'; + notes.push('picker opened and awaited interaction'); + } else { + directoryPicker = 'unsupported'; + notes.push(`picker rejected: ${message}`); + } + } + + return { appScopedWrite, directoryPicker, detail: notes.join(' | ') }; + }, + async selectVaultDirectory(): Promise { const result = await open({ directory: true, diff --git a/apps/client/src/routes/tauri-spike.tsx b/apps/client/src/routes/tauri-spike.tsx index abb738f..1e8609f 100644 --- a/apps/client/src/routes/tauri-spike.tsx +++ b/apps/client/src/routes/tauri-spike.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { tauriSpikeProbe } from '../lib/tauriSpikeProbe'; +import { runOfflineMapProbe } from '../lib/offlineMapProbe'; export const Route = createFileRoute('/tauri-spike')({ component: TauriSpikePage, @@ -9,8 +10,135 @@ export const Route = createFileRoute('/tauri-spike')({ function TauriSpikePage() { const [sqliteResult, setSqliteResult] = useState('Non exécuté'); const [vaultResult, setVaultResult] = useState('Non exécuté'); + const [mapResult, setMapResult] = useState('Non exécuté'); + const [isMapRunning, setIsMapRunning] = useState(false); + const mapContainerRef = useRef(null); const isNative = tauriSpikeProbe.isAvailable(); + // The map proof runs in the webview, so unlike the two native probes it is also + // meaningful in a plain browser; that is what makes it comparable across targets. + const verifyOfflineMap = useCallback(async () => { + if (!mapContainerRef.current) return; + setIsMapRunning(true); + setMapResult('Rendu en cours…'); + try { + const result = await runOfflineMapProbe(mapContainerRef.current); + if (result.sourceFeatures === 0) { + setMapResult('Échec : aucune entité décodée depuis l’archive embarquée.'); + return; + } + const blocked = + result.blockedRequests.length === 0 + ? 'aucune ressource réseau demandée' + : `${result.blockedRequests.length} ressource(s) réseau refusée(s) : ${result.blockedRequests.join(', ')}`; + const painted = + result.renderedFeatures === null + ? 'peinture non observable ici (rAF suspendu)' + : `${result.renderedFeatures} entités peintes`; + setMapResult( + `Hors-ligne : ${result.sourceFeatures} entités décodées depuis ${result.fixtureUrl}; ${painted}; ${blocked}.` + ); + } catch (error) { + setMapResult(`Erreur carte : ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsMapRunning(false); + } + }, []); + + // Unattended run for the target matrix: the Windows, iOS and Android smoke scripts + // cannot click, so the map probe runs on load and records its verdict where a + // harness can read it back — the spike database, plus a JSON file when the + // filesystem scope allows it. + useEffect(() => { + if (!isNative) return; + void (async () => { + if (!mapContainerRef.current) return; + // Range support is the thing PMTiles depends on: it reads a header, then a + // directory, then individual tiles, all as byte ranges of one file. + const rangeCheck: Record = {}; + try { + const probeUrl = new URL('/fixtures/r3a-countries.pmtiles', window.location.href).href; + const ranged = await fetch(probeUrl, { headers: { Range: 'bytes=0-126' } }); + const body = await ranged.arrayBuffer(); + rangeCheck.status = ranged.status; + rangeCheck.contentRange = ranged.headers.get('Content-Range'); + rangeCheck.bytes = body.byteLength; + rangeCheck.magic = new TextDecoder().decode(new Uint8Array(body, 0, 7)); + } catch (error) { + rangeCheck.error = error instanceof Error ? error.message : String(error); + } + + // Every step is bounded and recorded before the map runs, so a hang in one of + // them still leaves evidence behind instead of an empty table. + const withTimeout = (label: string, work: Promise, ms = 8000): Promise => + Promise.race([ + work, + new Promise((_, reject) => + window.setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) + ), + ]); + + const nativeRead: Record = {}; + try { + const { resolveResource } = await import('@tauri-apps/api/path'); + const path = await withTimeout( + 'resolveResource', + resolveResource('fixtures/r3a-countries.pmtiles') + ); + nativeRead.path = path; + + const { TauriFileSource } = await import('../lib/tauriPmtilesSource'); + const source = new TauriFileSource(path); + + const head = await withTimeout('getBytes(0,127)', source.getBytes(0, 127)); + nativeRead.headerBytes = head.data.byteLength; + nativeRead.magic = new TextDecoder().decode(new Uint8Array(head.data, 0, 7)); + + const { PMTiles } = await import('pmtiles'); + const archive = new PMTiles(source); + const header = await withTimeout('getHeader', archive.getHeader()); + nativeRead.maxZoom = header.maxZoom; + + const tile = await withTimeout('getZxy(2,1,1)', archive.getZxy(2, 1, 1)); + nativeRead.sampleTileBytes = tile?.data.byteLength ?? 0; + } catch (error) { + nativeRead.error = error instanceof Error ? error.message : String(error); + } + + await tauriSpikeProbe + .recordProbeVerdict('offline-map-diagnostics', { rangeCheck, nativeRead }) + .catch(() => {}); + + // What the vault can rely on here. On mobile this is the whole proof the spec + // asks for, since an arbitrary user-chosen folder may not exist as a concept. + const vaultCapability = await tauriSpikeProbe + .probeVaultCapability() + .catch((error) => ({ error: error instanceof Error ? error.message : String(error) })); + await tauriSpikeProbe.recordProbeVerdict('vault-capability', vaultCapability).catch(() => {}); + + let payload: Record; + try { + payload = { + ok: true, + rangeCheck, + nativeRead, + ...(await runOfflineMapProbe(mapContainerRef.current)), + }; + } catch (error) { + payload = { + ok: false, + rangeCheck, + nativeRead, + error: error instanceof Error ? error.message : String(error), + }; + } + setMapResult(JSON.stringify(payload)); + await tauriSpikeProbe.recordProbeVerdict('offline-map', payload).catch(() => { + // Recording is best effort; the on-screen result stays authoritative. + }); + })(); + }, [isNative]); + const verifySqlite = async () => { try { const result = await tauriSpikeProbe.verifySqlitePersistence(); @@ -79,6 +207,31 @@ function TauriSpikePage() { +
+
+

Carte hors-ligne (PMTiles)

+

+ Archive Natural Earth embarquée; tout accès réseau est bloqué pendant le rendu. +

+ + + {mapResult} + +
+
+
); } diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore index 502406b..480f9b8 100644 --- a/apps/desktop/src-tauri/.gitignore +++ b/apps/desktop/src-tauri/.gitignore @@ -1,4 +1,6 @@ # Generated by Cargo # will have compiled files and executables /target/ -/gen/schemas +# Generated mobile projects: regenerated with `tauri ios init` / `tauri android init`. +# They are build scaffolding, not spike source, and run to hundreds of megabytes. +/gen/ diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index d1a3f3c..aaaa457 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -11,7 +11,22 @@ "sql:allow-execute", "dialog:default", "fs:default", + "fs:allow-open", + "fs:allow-read-file", "fs:allow-read-text-file", - "fs:allow-write-text-file" + "fs:allow-write-text-file", + "fs:allow-seek", + "fs:allow-read", + "fs:allow-fstat", + "fs:allow-appdata-write-recursive", + "fs:allow-appdata-read-recursive", + { + "identifier": "fs:scope", + "allow": [ + { + "path": "$RESOURCE/fixtures/*" + } + ] + } ] } \ No newline at end of file diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 6a3e1e9..75e7626 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -36,6 +36,9 @@ ], "android": { "debugApplicationIdSuffix": ".debug" + }, + "resources": { + "../../client/public/fixtures/r3a-countries.pmtiles": "fixtures/r3a-countries.pmtiles" } } -} +} \ No newline at end of file diff --git a/docs/R3-A-EVIDENCE.md b/docs/R3-A-EVIDENCE.md index 49d0876..700a09e 100644 --- a/docs/R3-A-EVIDENCE.md +++ b/docs/R3-A-EVIDENCE.md @@ -1,8 +1,24 @@ # R3-A — Evidence bundle Preuves reproductibles du spike Tauri 2 (voir [R3-A-TAURI-SPIKE.md](R3-A-TAURI-SPIKE.md)). -Aucune conclusion Go/No-go n'est déclarée tant que la matrice complète (macOS, Windows, -iOS, Android) et la preuve PMTiles hors-ligne ne sont pas couvertes. + +## Décision : GO + +Déclarée par Naomi Gilbert le 2026-08-03, sur la base des preuves ci-dessous : les trois +capacités requises — SQLite local, écriture Markdown, carte PMTiles hors-ligne — sont +exercées et passent sur macOS, sur le simulateur iOS et sur l'émulateur Android, soit +deux moteurs de webview distincts. + +Deux points sont ouverts et portés avec la décision, ils ne sont pas refermés par elle : + +1. **Windows n'a pas été testé** (décision du 2026-08-03). Risque résiduel jugé faible + depuis qu'Android valide la pile sur un webview Chromium, mais ce n'est pas une preuve. +2. **Le modèle de vault mobile reste à trancher.** Le sélecteur de dossier n'existe pas + sur iOS ni Android; le vault du bureau n'a donc pas d'équivalent direct. Trois options + sont documentées plus bas et le choix conditionne la conception du vault. À décider + avant R3-B. + +Le fallback ratifié en D8 (Capacitor + Electron) n'est pas retenu. ## Environnement (macOS) @@ -61,12 +77,223 @@ doit ajouter explicitement : `CREATE TABLE`/`INSERT` sont refusés à l'exécution. - `fs:allow-read-text-file` et `fs:allow-write-text-file` — `fs:default` ne couvre que la lecture des dossiers applicatifs. +- `fs:allow-open`, `fs:allow-seek`, `fs:allow-read`, `fs:allow-fstat` — l'API bas niveau + de descripteurs de fichier a ses propres commandes ACL, indépendantes des helpers + `readFile`/`writeTextFile`. Sans elles : « Command plugin:fs|seek not allowed by ACL ». + Attention, `fs:allow-close` **n'existe pas** et fait échouer la compilation. +- Portée `fs:scope` sur `$RESOURCE/fixtures/*` pour lire l'archive embarquée. + +## macOS — PMTiles hors-ligne (2026-08-03) : PASS + +Sonde exécutée automatiquement au démarrage de la fenêtre native, verdict écrit dans la +base du spike (`probe_verdicts`) pour être relu sans interaction : + +```json +{ + "ok": true, + "sourceFeatures": 87, + "renderedFeatures": 7, + "blockedRequests": [], + "transport": "tauri-fs" +} +``` + +- **87 entités décodées** depuis l'archive embarquée et **7 réellement peintes** — la carte + s'affiche, elle n'est pas seulement chargée. +- **`blockedRequests: []`** : aucune ressource réseau n'a même été demandée. Le style + n'utilise ni glyphes ni sprites, qui seraient des dépendances réseau cachées. +- Fixture : polygones de pays Natural Earth (domaine public, donc redistribuable), + générée depuis les données déjà présentes dans le dépôt par + `packages/core/scripts/build-pmtiles-fixture.mjs`. **Aucun téléchargement, aucune donnée + dérivée d'OSM, aucun préchargement de serveur de tuiles.** 598 Kio, zooms 0 à 4. + +### Contrainte plateforme majeure : pas de byte serving sur `tauri://` + +PMTiles lit une archive par plages d'octets. Le protocole applicatif de Tauri **ignore +l'en-tête `Range`** : + +| Demande | Réponse | +| --- | --- | +| `Range: bytes=0-126` | `200`, `Content-Range: null`, **612 390 octets** (fichier entier) | + +La bibliothèque refuse explicitement ce backend : *« Check that your storage backend +supports HTTP Byte Serving »*. **Servir l'archive via `tauri://localhost` ne fonctionne +donc pas.** + +Contournement retenu et prouvé : embarquer l'archive comme ressource +(`bundle.resources`) et la lire par plages avec le plugin `fs` +(`apps/client/src/lib/tauriPmtilesSource.ts`). C'est plus proche de ce que ferait une +application locale de toute façon. + +### Piège de concurrence à retenir + +Un `seek` suivi d'un `read` sont deux allers-retours sur **un curseur partagé**, et +MapLibre demande plusieurs tuiles simultanément. Sans sérialisation des accès, les seeks +s'entrelacent et chaque lecture reçoit les octets d'une autre plage; le symptôme +apparaît très loin de la cause, sous la forme d'une tuile corrompue (« Extra bytes past +the end »). La source sérialise donc ses lectures. + +### Validation de la fixture + +Indépendamment du spike, `apps/client/src/lib/__tests__/pmtilesFixture.test.ts` vérifie +l'en-tête v3, le type MVT, la couche déclarée et la taille. Les tuiles ont par ailleurs +été parsées avec `@mapbox/vector-tile` : 7 tuiles sur 7 décodées avec la couche +`countries` peuplée. + +## Exécution non assistée + +La sonde carte s'exécute au démarrage et écrit son verdict dans la table +`probe_verdicts` de la base du spike. Les smoke tests Windows, iOS et Android pourront +donc lire le résultat sans piloter d'interface : + +```sh +sqlite3 "/hap-r3-a-spike.sqlite" \ + "SELECT verdict FROM probe_verdicts WHERE probe='offline-map';" +``` + +## iOS simulator (2026-08-03) : PASS + +iPhone 17 Pro, runtime iOS 26.3.1, build `tauri ios build --target aarch64-sim`, +installé et lancé via `simctl`. Verdict relu dans le conteneur de l'application : + +```json +{ "ok": true, "sourceFeatures": 48, "renderedFeatures": 3, + "blockedRequests": [], "transport": "tauri-fs" } +``` + +- **Carte hors-ligne : PASS** — 48 entités décodées, 3 peintes, aucune requête réseau. + L'archive est lue depuis le bundle de l'app + (`…/HAP Tauri Spike.app/assets/fixtures/r3a-countries.pmtiles`). +- **SQLite : PASS** — la table `probe_verdicts` est créée et écrite par l'application + elle-même; sans SQLite fonctionnel il n'y aurait aucun verdict à lire. +- **Vault : contrôle de capacité PASS, avec une contrainte majeure** — voir la section + dédiée ci-dessous. + +Moins d'entités que sur macOS (48 contre 87) simplement parce que la fenêtre est plus +petite : moins de tuiles sont dans le champ de vue. + +## Android emulator (2026-08-03) : PASS + +AVD `Medium_Phone_API_36.1` (API 36.1), APK debug universel installé via `adb`. + +```json +{ "ok": true, "sourceFeatures": 48, "renderedFeatures": 3, + "blockedRequests": [], "transport": "tauri-fs" } +``` + +- **Carte hors-ligne : PASS**, **SQLite : PASS**, **Vault : capacité vérifiée** — mêmes + conclusions qu'iOS. +- **Différence de plateforme à noter :** `resolveResource` renvoie ici + `asset://localhost/fixtures/r3a-countries.pmtiles`, une URI et non un chemin de + système de fichiers. Le plugin `fs` l'ouvre et la lit par plages sans adaptation : + la même source PMTiles fonctionne donc sur les trois plateformes malgré des formes + d'adresse différentes. +- L'identifiant du paquet installé porte le suffixe `.debug` + (`android.debugApplicationIdSuffix`), à savoir pour les scripts de smoke. + +## Reproduire les builds mobiles + +Les projets `gen/apple` et `gen/android` ne sont pas versionnés : ce sont des +échafaudages de build (855 Mo), régénérés à l'identique par `init`. + +```sh +export PATH="$HOME/.cargo/bin:$PATH" # rustup, pas le rust Homebrew +export ANDROID_HOME="$HOME/Library/Android/sdk" +export NDK_HOME="$ANDROID_HOME/ndk/27.0.12077973" +export JAVA_HOME="/opt/homebrew/opt/openjdk@17" + +pnpm --dir apps/desktop tauri ios init +pnpm --dir apps/desktop tauri ios build --target aarch64-sim # au premier plan + +pnpm --dir apps/desktop tauri android init +pnpm --dir apps/desktop tauri android build --debug --target aarch64 +``` + +## Vault sur mobile : le sélecteur de dossier n'existe pas + +Contrôle non assisté exécuté sur les trois plateformes le 2026-08-03. + +| Plateforme | Écriture en stockage applicatif | Sélecteur de dossier | +| --- | --- | --- | +| macOS | PASS (aller-retour UTF-8) | **supporté** | +| iOS 26.3.1 | PASS (aller-retour UTF-8) | **non supporté** | +| Android 36.1 | PASS (aller-retour UTF-8) | **non supporté** | + +Message renvoyé par le plugin sur les deux plateformes mobiles : + +``` +Folder picker is not implemented on mobile +``` + +**Conséquence pour l'architecture, à trancher avant R3-B.** Le vault du bureau repose sur +un dossier arbitraire choisi par l'utilisateur — typiquement un dossier Obsidian ou un +dossier synchronisé. Cette notion **n'existe pas** sur iOS ni Android avec la pile +actuelle. Le mobile peut écrire du Markdown, mais uniquement dans le stockage propre à +l'application, invisible aux autres applications et supprimé avec elle. + +Les options se limitent donc à : + +1. **Vault en stockage applicatif sur mobile**, avec import/export explicite. Simple, + mais le vault mobile n'est plus le même objet que celui du bureau. +2. **Passer par le sélecteur de documents du système** (`UIDocumentPicker` sur iOS, + Storage Access Framework sur Android) via un plugin Tauri à écrire ou à trouver. Les + accès y sont accordés par URI, souvent limités dans le temps, et se réautorisent — ce + n'est pas un chemin de système de fichiers stable. +3. **Vault desktop uniquement**, le mobile étant en lecture/consultation. + +Le spec exige de consigner la contrainte plutôt que de simuler les sémantiques du bureau; +c'est fait ici. **Le choix reste ouvert et conditionne la conception du vault.** + +## Chaîne d'outils mobile : ce qu'il a fallu + +Rien de tout cela ne relève de Tauri ni du code du spike, mais tout a bloqué un build : + +- **Rust doit venir de rustup**, pas de Homebrew : la formule ne fournit que la cible + hôte, sans bibliothèques standard iOS/Android. +- **`tauri ios build` doit tourner au premier plan.** Le script Xcode « Build Rust Code » + se connecte en WebSocket au CLI parent; détaché, la connexion est refusée et le build + échoue en `Abort trap: 6`. +- **Le runtime simulateur doit exister**, et `xcodebuild -downloadPlatform iOS` installe + toujours la dernière version, sans possibilité de cibler. SDK 26.2 + runtime 26.3.1 + fonctionne. +- **Gradle 8.14.3 refuse les JDK trop récents** : JDK 26 donne « Unsupported class file + major version 70 ». JDK 17 fonctionne. Le cask Temurin exige sudo; la formule + `openjdk@17` non. + +## Windows : SUPPOSÉ, NON TESTÉ + +Décision prise le 2026-08-03 : Windows n'est pas exécuté. **Ce n'est pas une preuve** et +le dossier ne le compte pas comme telle — mais le risque résiduel est faible et il a +beaucoup baissé depuis. + +Ce qui rassure : les trois capacités passent désormais sur **trois moteurs de webview +distincts** — WKWebView (macOS et iOS) et le WebView Android (Chromium). MapLibre, WebGL, +le plugin SQLite et la lecture par plages du plugin `fs` fonctionnent donc déjà sur du +Chromium, qui est ce que Windows utilise via WebView2. Le contournement adopté ne dépend +d'ailleurs pas du protocole applicatif, puisqu'il lit le fichier nativement. + +Ce qui reste néanmoins non vérifié : + +- WebView2 est un Chromium distinct de celui d'Android, avec sa propre pile graphique; + l'accélération WebGL sous Windows dépend du pilote et bascule parfois sur un rendu + logiciel. +- Les chemins de ressources et la portée `fs` y ont une autre forme — Android a déjà + montré une troisième forme (`asset://localhost/...`), donc cette partie varie + réellement d'une plateforme à l'autre. -## macOS — PMTiles hors-ligne : NON EXÉCUTÉ +## État de la matrice -Fixture PMTiles et sonde carte non implémentées à ce jour. Bloquant pour toute -conclusion, y compris un go conditionnel. +| Cible | Build | SQLite | Vault | PMTiles hors-ligne | +| --- | --- | --- | --- | --- | +| macOS | PASS | PASS | PASS | PASS | +| iOS simulator | PASS | PASS | capacité vérifiée (contrainte) | PASS | +| Android emulator | PASS | PASS | capacité vérifiée (contrainte) | PASS | +| Windows | supposé | supposé | supposé | supposé | -## Cibles restantes +Une seule réserve subsiste côté exécution : Windows n'a pas été testé (décision du +2026-08-03), avec un risque résiduel jugé faible depuis qu'Android valide la pile sur un +webview Chromium. -Windows, iOS simulator, Android emulator : non exécutées. +Le contrôle de capacité du vault mobile est fait, et il est concluant au sens du spec : +il a **révélé une contrainte** qui n'invalide pas la faisabilité de Tauri, mais qui +impose un choix d'architecture pour le vault mobile avant R3-B. diff --git a/packages/core/package.json b/packages/core/package.json index 663933c..9bea6df 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,5 +7,9 @@ "types": "./src/index.ts", "scripts": { "build:gazetteer": "node scripts/build-gazetteer.mjs" + }, + "devDependencies": { + "geojson-vt": "^4.0.3", + "vt-pbf": "^3.1.3" } } diff --git a/packages/core/scripts/build-pmtiles-fixture.mjs b/packages/core/scripts/build-pmtiles-fixture.mjs new file mode 100644 index 0000000..0eda8cc --- /dev/null +++ b/packages/core/scripts/build-pmtiles-fixture.mjs @@ -0,0 +1,258 @@ +/** + * Builds the R3-A spike's offline map fixture. + * + * Source is the Natural Earth country geometry already bundled for the gazetteer + * (public domain, so the archive is redistributable). Nothing is fetched: the spike + * must prove the map renders with no network at all, which rules out OSM tile + * prefetching or pulling a third-party basemap. + * + * Output is a PMTiles v3 archive of Mapbox Vector Tiles. The v3 container is written + * here rather than shelled out to tippecanoe/pmtiles so the fixture can be rebuilt + * from a clean checkout with nothing but pnpm. + * + * Usage: node scripts/build-pmtiles-fixture.mjs + */ +import { createRequire } from "node:module"; +import { gzipSync } from "node:zlib"; +import fs from "node:fs"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const geojsonvt = require("geojson-vt").default ?? require("geojson-vt"); +const vtpbf = require("vt-pbf"); + +const MAX_ZOOM = 4; +const LAYER_NAME = "countries"; +const OUT_PATH = path.resolve( + import.meta.dirname, + "../../../apps/client/public/fixtures/r3a-countries.pmtiles", +); + +/** PMTiles v3 encodes tile coordinates as a Hilbert curve index. */ +function zxyToTileId(z, x, y) { + if (z === 0) return 0n; + let acc = 0n; + for (let t = 0; t < z; t++) { + acc += (1n << BigInt(t)) * (1n << BigInt(t)); + } + const n = 1n << BigInt(z); + let rx = 0n; + let ry = 0n; + let d = 0n; + let bx = BigInt(x); + let by = BigInt(y); + for (let s = n / 2n; s > 0n; s /= 2n) { + rx = (bx & s) > 0n ? 1n : 0n; + ry = (by & s) > 0n ? 1n : 0n; + d += s * s * ((3n * rx) ^ ry); + // rotate + if (ry === 0n) { + if (rx === 1n) { + bx = s - 1n - bx; + by = s - 1n - by; + } + const tmp = bx; + bx = by; + by = tmp; + } + } + return acc + d; +} + +function writeVarint(value) { + const bytes = []; + let v = BigInt(value); + while (v >= 0x80n) { + bytes.push(Number((v & 0x7fn) | 0x80n)); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +} + +/** Directory serialization per the PMTiles v3 spec: four delta-encoded varint blocks. */ +function serializeDirectory(entries) { + const parts = [writeVarint(entries.length)]; + + let lastId = 0n; + for (const entry of entries) { + parts.push(writeVarint(entry.tileId - lastId)); + lastId = entry.tileId; + } + for (const entry of entries) parts.push(writeVarint(entry.runLength)); + for (const entry of entries) parts.push(writeVarint(entry.length)); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const previous = entries[i - 1]; + if (i > 0 && previous.offset + previous.length === entry.offset) { + parts.push(writeVarint(0)); + } else { + parts.push(writeVarint(entry.offset + 1n)); + } + } + + return Buffer.concat(parts); +} + +function buildHeader(fields) { + const header = Buffer.alloc(127); + header.write("PMTiles", 0, "ascii"); + header.writeUInt8(3, 7); + header.writeBigUInt64LE(fields.rootOffset, 8); + header.writeBigUInt64LE(fields.rootLength, 16); + header.writeBigUInt64LE(fields.metadataOffset, 24); + header.writeBigUInt64LE(fields.metadataLength, 32); + header.writeBigUInt64LE(fields.leafOffset, 40); + header.writeBigUInt64LE(fields.leafLength, 48); + header.writeBigUInt64LE(fields.tileDataOffset, 56); + header.writeBigUInt64LE(fields.tileDataLength, 64); + header.writeBigUInt64LE(fields.addressedTiles, 72); + header.writeBigUInt64LE(fields.tileEntries, 80); + header.writeBigUInt64LE(fields.tileContents, 88); + header.writeUInt8(0, 96); // not clustered by leaf directories + header.writeUInt8(2, 97); // internal compression: gzip + header.writeUInt8(2, 98); // tile compression: gzip + header.writeUInt8(1, 99); // tile type: MVT + header.writeUInt8(fields.minZoom, 100); + header.writeUInt8(fields.maxZoom, 101); + header.writeInt32LE(Math.round(fields.minLon * 1e7), 102); + header.writeInt32LE(Math.round(fields.minLat * 1e7), 106); + header.writeInt32LE(Math.round(fields.maxLon * 1e7), 110); + header.writeInt32LE(Math.round(fields.maxLat * 1e7), 114); + header.writeUInt8(fields.centerZoom, 118); + header.writeInt32LE(Math.round(fields.centerLon * 1e7), 119); + header.writeInt32LE(Math.round(fields.centerLat * 1e7), 123); + return header; +} + +async function main() { + const { bundledGazetteerDataset } = await import("../src/gazetteer/data.ts"); + + const features = bundledGazetteerDataset.countries.map((country) => ({ + type: "Feature", + properties: { name: country.name }, + geometry: { + type: "MultiPolygon", + coordinates: country.rings.map((ring) => [ + ring.map(([lng, lat]) => [lng, lat]), + ]), + }, + })); + + console.log(`Tiling ${features.length} country features up to z${MAX_ZOOM}…`); + + const index = geojsonvt( + { type: "FeatureCollection", features }, + { maxZoom: MAX_ZOOM, indexMaxZoom: MAX_ZOOM, buffer: 64 }, + ); + + // Collect every non-empty tile, keyed by its Hilbert id so the directory is sorted. + const tiles = []; + for (let z = 0; z <= MAX_ZOOM; z++) { + const side = 1 << z; + for (let x = 0; x < side; x++) { + for (let y = 0; y < side; y++) { + const tile = index.getTile(z, x, y); + if (!tile || tile.features.length === 0) continue; + const buffer = Buffer.from( + vtpbf.fromGeojsonVt({ [LAYER_NAME]: tile }, { version: 2 }), + ); + if (buffer.length === 0) continue; + tiles.push({ tileId: zxyToTileId(z, x, y), data: gzipSync(buffer) }); + } + } + } + + tiles.sort((a, b) => + a.tileId < b.tileId ? -1 : a.tileId > b.tileId ? 1 : 0, + ); + console.log(`${tiles.length} non-empty tiles`); + + // Identical tiles (large empty ocean areas) are stored once and shared. + const seen = new Map(); + const tileBlobs = []; + const entries = []; + let dataOffset = 0n; + + for (const tile of tiles) { + const key = tile.data.toString("base64"); + let placement = seen.get(key); + if (!placement) { + placement = { offset: dataOffset, length: BigInt(tile.data.length) }; + seen.set(key, placement); + tileBlobs.push(tile.data); + dataOffset += BigInt(tile.data.length); + } + entries.push({ + tileId: tile.tileId, + offset: placement.offset, + length: placement.length, + runLength: 1n, + }); + } + + const metadata = Buffer.from( + JSON.stringify({ + name: "HAP R3-A offline fixture", + description: + "Natural Earth country polygons, bundled for the R3-A Tauri spike offline map proof.", + attribution: "Natural Earth (public domain)", + vector_layers: [ + { + id: LAYER_NAME, + fields: { name: "String" }, + minzoom: 0, + maxzoom: MAX_ZOOM, + }, + ], + }), + "utf-8", + ); + + const rootDirectory = gzipSync(serializeDirectory(entries)); + const metadataGz = gzipSync(metadata); + const tileData = Buffer.concat(tileBlobs); + + const rootOffset = 127n; + const metadataOffset = rootOffset + BigInt(rootDirectory.length); + const leafOffset = metadataOffset + BigInt(metadataGz.length); + const tileDataOffset = leafOffset; + + const header = buildHeader({ + rootOffset, + rootLength: BigInt(rootDirectory.length), + metadataOffset, + metadataLength: BigInt(metadataGz.length), + leafOffset, + leafLength: 0n, + tileDataOffset, + tileDataLength: BigInt(tileData.length), + addressedTiles: BigInt(entries.length), + tileEntries: BigInt(entries.length), + tileContents: BigInt(tileBlobs.length), + minZoom: 0, + maxZoom: MAX_ZOOM, + minLon: -180, + minLat: -85, + maxLon: 180, + maxLat: 85, + centerZoom: 0, + centerLon: 0, + centerLat: 0, + }); + + fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true }); + fs.writeFileSync( + OUT_PATH, + Buffer.concat([header, rootDirectory, metadataGz, tileData]), + ); + + const size = fs.statSync(OUT_PATH).size; + console.log(`Wrote ${OUT_PATH} (${(size / 1024).toFixed(1)} KiB)`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/core/src/interfaces/LocalPlatformProbe.ts b/packages/core/src/interfaces/LocalPlatformProbe.ts index 7cc4042..0b4e8f4 100644 --- a/packages/core/src/interfaces/LocalPlatformProbe.ts +++ b/packages/core/src/interfaces/LocalPlatformProbe.ts @@ -8,9 +8,40 @@ export interface VaultProbeResult { contents: string; } +export interface OfflineMapProbeResult { + /** Features decoded from the bundled archive. Independent of painting. */ + sourceFeatures: number; + /** + * Features actually painted. Null when the host never ran an animation frame — + * a headless or backgrounded webview throttles rAF, which is a property of the + * environment rather than of the map. + */ + renderedFeatures: number | null; + /** Any resource other than the fixture that the map asked for. */ + blockedRequests: string[]; + fixtureUrl: string; + /** How the archive bytes were obtained: native file reads, or HTTP. */ + transport: "tauri-fs" | "http"; +} + +export interface VaultCapabilityResult { + /** A Markdown file written and read back inside app-scoped storage. */ + appScopedWrite: { path: string; roundTrip: boolean } | null; + /** + * Whether the platform can hand the app an arbitrary user-chosen directory, which + * is what the desktop vault relies on. + */ + directoryPicker: "supported" | "unsupported" | "unknown"; + detail: string; +} + export interface LocalPlatformProbe { isAvailable(): boolean; verifySqlitePersistence(): Promise; selectVaultDirectory(): Promise; writeVaultProbe(directory: string): Promise; + /** Persists a probe verdict so unattended smoke runs can be read back later. */ + recordProbeVerdict(probe: string, verdict: unknown): Promise; + /** Unattended check of what the vault can rely on, per platform. */ + probeVaultCapability(): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7756f90..70fb75a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: maplibre-gl: specifier: ^5.24.0 version: 5.24.0 + pmtiles: + specifier: ^4.4.1 + version: 4.4.1 react: specifier: ^19.2.7 version: 19.2.7 @@ -84,6 +87,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) + '@mapbox/vector-tile': + specifier: ^3.0.0 + version: 3.0.0 '@playwright/test': specifier: ^1.61.1 version: 1.61.1 @@ -135,6 +141,9 @@ importers: oxlint: specifier: ^1.71.0 version: 1.72.0 + pbf: + specifier: ^5.1.2 + version: 5.1.2 prettier: specifier: ^3.9.4 version: 3.9.4 @@ -167,7 +176,14 @@ importers: specifier: ^2.11.4 version: 2.11.4 - packages/core: {} + packages/core: + devDependencies: + geojson-vt: + specifier: ^4.0.3 + version: 4.0.3 + vt-pbf: + specifier: ^3.1.3 + version: 3.1.3 packages/export: {} @@ -434,6 +450,9 @@ packages: resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==} engines: {node: '>= 22'} + '@mapbox/point-geometry@0.1.0': + resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} + '@mapbox/point-geometry@1.1.0': resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} @@ -446,9 +465,15 @@ packages: '@mapbox/unitbezier@1.0.0': resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==} + '@mapbox/vector-tile@1.3.1': + resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} + '@mapbox/vector-tile@2.0.5': resolution: {integrity: sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==} + '@mapbox/vector-tile@3.0.0': + resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==} + '@mapbox/whoots-js@3.1.0': resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} engines: {node: '>=6.0.0'} @@ -1517,6 +1542,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geojson-vt@4.0.3: + resolution: {integrity: sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -1553,6 +1581,9 @@ packages: typescript: optional: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1841,6 +1872,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pbf@3.3.0: + resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} + hasBin: true + pbf@4.0.2: resolution: {integrity: sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==} hasBin: true @@ -1866,6 +1901,9 @@ packages: engines: {node: '>=18'} hasBin: true + pmtiles@4.4.1: + resolution: {integrity: sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==} + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -2239,6 +2277,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vt-pbf@3.1.3: + resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -2575,6 +2616,8 @@ snapshots: '@mapbox/jsonlint-lines-primitives@2.0.3': {} + '@mapbox/point-geometry@0.1.0': {} + '@mapbox/point-geometry@1.1.0': {} '@mapbox/tiny-sdf@2.2.0': {} @@ -2583,12 +2626,22 @@ snapshots: '@mapbox/unitbezier@1.0.0': {} + '@mapbox/vector-tile@1.3.1': + dependencies: + '@mapbox/point-geometry': 0.1.0 + '@mapbox/vector-tile@2.0.5': dependencies: '@mapbox/point-geometry': 1.1.0 '@types/geojson': 7946.0.16 pbf: 4.0.2 + '@mapbox/vector-tile@3.0.0': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 5.1.2 + '@mapbox/whoots-js@3.1.0': {} '@maplibre/geojson-vt@6.1.1': @@ -3518,6 +3571,8 @@ snapshots: gensync@1.0.0-beta.2: {} + geojson-vt@4.0.3: {} + get-east-asian-width@1.6.0: {} gl-matrix@3.4.4: {} @@ -3546,6 +3601,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3819,6 +3876,11 @@ snapshots: pathe@2.0.3: {} + pbf@3.3.0: + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + pbf@4.0.2: dependencies: resolve-protobuf-schema: 2.1.0 @@ -3839,6 +3901,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pmtiles@4.4.1: + dependencies: + fflate: 0.8.3 + postcss@8.5.16: dependencies: nanoid: 3.3.15 @@ -4116,6 +4182,12 @@ snapshots: void-elements@3.1.0: {} + vt-pbf@3.1.3: + dependencies: + '@mapbox/point-geometry': 0.1.0 + '@mapbox/vector-tile': 1.3.1 + pbf: 3.3.0 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0