From 8634f111da360fccf8fa75cf81cff51346a4cd7a Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 12:09:38 -0700 Subject: [PATCH 01/13] Replace browser shard routing with virtual world tiles --- .env.example | 17 +- .github/workflows/audit-world-release-v2.yml | 18 +- .github/workflows/audit-world-release.yml | 10 +- .../workflows/build-virtual-world-tileset.yml | 159 ++ .github/workflows/clean-world-release.yml | 24 +- .github/workflows/finish-world-pmtiles.yml | 8 +- .github/workflows/inventory-world-release.yml | 8 +- .../repair-missing-world-pmtiles.yml | 14 +- .github/workflows/repair-world-pmtiles.yml | 14 +- README.md | 43 +- package-lock.json | 1820 ++++++++++++++++ package.json | 9 +- planetiler/README.md | 44 +- scripts/apply-globe-parity.mjs | 33 +- scripts/apply-photo-reference.mjs | 26 - scripts/build-runtime-style.mjs | 48 +- scripts/build-world-manifest.mjs | 39 +- scripts/build-world-overview.mjs | 136 ++ scripts/calibrate-reference-colors.mjs | 36 +- scripts/capture-pmtiles-preview.mjs | 182 -- scripts/capture-world-fresno-preview.mjs | 188 +- scripts/check-exact-exported-swatches.mjs | 60 +- scripts/check-globe-parity.mjs | 19 +- scripts/check-live-world-tiles.mjs | 66 + scripts/check-low-zoom-relief.mjs | 44 - scripts/check-photo-reference.mjs | 21 +- scripts/check-pmtiles-integration.mjs | 167 +- scripts/check-render-clarity.mjs | 22 +- scripts/check-runtime.mjs | 6 +- scripts/check-viewer-quality.mjs | 18 +- scripts/check-world-tile-gateway.mjs | 166 ++ scripts/lock-exact-exported-swatches.mjs | 54 +- scripts/pack-mbtiles.py | 69 + scripts/strengthen-low-zoom-relief.mjs | 51 - scripts/use-custom-pmtiles-source.mjs | 58 - server.mjs | 222 +- src/occumed-map.js | 33 +- src/server/mvt.js | 285 +++ src/server/pmtiles-source.js | 46 + src/server/world-tile-gateway.js | 259 +++ src/server/world-tile-routing.js | 128 ++ src/world-pmtiles-router.js | 103 - visual-validation/fresno-pmtiles/README.md | 13 - .../occumed-fresno.manifest.txt | 7 - .../fresno-pmtiles/pmtiles-range.headers.txt | 18 - .../fresno-pmtiles/preview-report.json | 1845 ----------------- 46 files changed, 3566 insertions(+), 3090 deletions(-) create mode 100644 .github/workflows/build-virtual-world-tileset.yml create mode 100644 package-lock.json create mode 100644 scripts/build-world-overview.mjs delete mode 100644 scripts/capture-pmtiles-preview.mjs create mode 100644 scripts/check-live-world-tiles.mjs delete mode 100644 scripts/check-low-zoom-relief.mjs create mode 100644 scripts/check-world-tile-gateway.mjs create mode 100644 scripts/pack-mbtiles.py delete mode 100644 scripts/strengthen-low-zoom-relief.mjs delete mode 100644 scripts/use-custom-pmtiles-source.mjs create mode 100644 src/server/mvt.js create mode 100644 src/server/pmtiles-source.js create mode 100644 src/server/world-tile-gateway.js create mode 100644 src/server/world-tile-routing.js delete mode 100644 src/world-pmtiles-router.js delete mode 100644 visual-validation/fresno-pmtiles/README.md delete mode 100644 visual-validation/fresno-pmtiles/occumed-fresno.manifest.txt delete mode 100644 visual-validation/fresno-pmtiles/pmtiles-range.headers.txt delete mode 100644 visual-validation/fresno-pmtiles/preview-report.json diff --git a/.env.example b/.env.example index 7cbd0e45..0a45bfb8 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,22 @@ # Optional browser override. The default is the generated shared style. VITE_OCCUMED_STYLE_URL=/style/occumed-open.json -# Optional build-time endpoints. Defaults use no-key public open-data services. -OCCUMED_TILEJSON_URL=https://tiles.openfreemap.org/planet -OCCUMED_GLYPHS_URL=https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf +# The browser always uses one same-origin vector URL. This override is intended +# only for a compatible deployment of the Occu-Med virtual tile gateway. +OCCUMED_VECTOR_TILES_URL=__OCCUMED_PUBLIC_ORIGIN__/tiles/{z}/{x}/{y}.pbf + +# Optional build-time terrain endpoint. The vector basemap has no external +# fallback and glyphs are rendered locally by MapLibre. OCCUMED_TERRAIN_URL=https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png -OCCUMED_RELIEF_URL=https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png # Set this once on the deployed Map service so the shared style contains # an absolute sprite URL usable by Atlas, Insight Hub, Network Map, etc. # Example: https://map.example.com PUBLIC_ORIGIN= + +# Server-only PMTiles storage configuration. These URLs are never placed in the +# browser style. +OCCUMED_WORLD_RELEASE_REPOSITORY=Occumed79/Map +OCCUMED_WORLD_RELEASE_TAG=occumed-world-v1 +OCCUMED_WORLD_MANIFEST_URL= +OCCUMED_TILE_CACHE_MAX_BYTES=134217728 diff --git a/.github/workflows/audit-world-release-v2.yml b/.github/workflows/audit-world-release-v2.yml index b4c2f0f4..e3d2cabc 100644 --- a/.github/workflows/audit-world-release-v2.yml +++ b/.github/workflows/audit-world-release-v2.yml @@ -47,7 +47,7 @@ jobs: > final-world-audit/release-pages.json gh release download "$WORLD_RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ - --pattern world-manifest.json \ + --pattern world-virtual-manifest.json \ --dir final-world-audit/release node <<'NODE' @@ -55,13 +55,17 @@ jobs: const plan = JSON.parse(fs.readFileSync('final-world-audit/pinned-plan.json', 'utf8')); const pages = JSON.parse(fs.readFileSync('final-world-audit/release-pages.json', 'utf8')); - const manifest = JSON.parse(fs.readFileSync('final-world-audit/release/world-manifest.json', 'utf8')); + const manifest = JSON.parse(fs.readFileSync('final-world-audit/release/world-virtual-manifest.json', 'utf8')); const regions = plan.include || []; const assets = pages.flatMap((page) => page || []); const byName = new Map(assets.map((asset) => [asset.name, asset])); const plannedNames = regions.map((region) => region.asset_name); const plannedNameSet = new Set(plannedNames); + const virtualAssetNames = new Set([ + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles' + ]); const plannedIds = regions.map((region) => region.id); const plannedIdSet = new Set(plannedIds); @@ -71,7 +75,11 @@ jobs: const missing = regions.filter((region) => !byName.has(region.asset_name)); const zeroSize = regions.filter((region) => Number(byName.get(region.asset_name)?.size || 0) <= 0); - const extras = assets.filter((asset) => asset.name.endsWith('.pmtiles') && !plannedNameSet.has(asset.name)); + const extras = assets.filter((asset) => + asset.name.endsWith('.pmtiles') && + !plannedNameSet.has(asset.name) && + !virtualAssetNames.has(asset.name) + ); const manifestRegions = manifest.regions || []; const manifestNames = manifestRegions.map((region) => region.asset); @@ -107,6 +115,10 @@ jobs: if (manifestNameSet.size !== manifestNames.length) structuralErrors.push('manifest contains duplicate assets'); if (manifestIdSet.size !== manifestIds.length) structuralErrors.push('manifest contains duplicate IDs'); if ((manifest.missingRegions || []).length !== 0) structuralErrors.push('manifest missingRegions is not empty'); + if (Number(manifest.version) !== 2) structuralErrors.push(`manifest version=${manifest.version}, expected=2`); + if (manifest.virtualTiles?.endpoint !== '/tiles/{z}/{x}/{y}.pbf') structuralErrors.push('manifest virtual tile endpoint is missing or incorrect'); + if (manifest.virtualTiles?.overviewAsset !== 'occumed-world-overview.pmtiles') structuralErrors.push('manifest overview asset is missing or incorrect'); + if (manifest.virtualTiles?.surfaceAsset !== 'occumed-world-surface.pmtiles') structuralErrors.push('manifest surface asset is missing or incorrect'); const summary = { capturedAt: new Date().toISOString(), diff --git a/.github/workflows/audit-world-release.yml b/.github/workflows/audit-world-release.yml index 738c4c40..abf513c3 100644 --- a/.github/workflows/audit-world-release.yml +++ b/.github/workflows/audit-world-release.yml @@ -193,8 +193,16 @@ jobs: const missingIds = new Set(missing.map((region) => region.id)); const repairTargets = plan.include.filter((region) => missingIds.has(region.id) || !healthyIds.has(region.id)); const canonicalAssetNames = new Set(plan.include.map((region) => region.asset_name)); + const virtualAssetNames = new Set([ + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles' + ]); const extraPmtilesAssets = release.assets - .filter((asset) => asset.name.endsWith('.pmtiles') && !canonicalAssetNames.has(asset.name)) + .filter((asset) => + asset.name.endsWith('.pmtiles') && + !canonicalAssetNames.has(asset.name) && + !virtualAssetNames.has(asset.name) + ) .map((asset) => ({ id: asset.id, name: asset.name, size: asset.size, updatedAt: asset.updated_at })); const summary = { capturedAt: new Date().toISOString(), diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml new file mode 100644 index 00000000..bbce0642 --- /dev/null +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -0,0 +1,159 @@ +name: Build Virtual Worldwide Tileset + +on: + workflow_dispatch: + push: + branches: + - main + - agent/virtual-worldwide-tileset + paths: + - .github/workflows/build-virtual-world-tileset.yml + - package-lock.json + - package.json + - planetiler/** + - scripts/build-world-manifest.mjs + - scripts/build-world-overview.mjs + - scripts/check-live-world-tiles.mjs + - scripts/pack-mbtiles.py + - scripts/plan-world-shards-final.mjs + - src/server/** + +permissions: + contents: write + +concurrency: + group: occumed-virtual-world-tileset + cancel-in-progress: false + +env: + WORLD_RELEASE_TAG: occumed-world-v1 + OVERVIEW_ASSET: occumed-world-overview.pmtiles + SURFACE_ASSET: occumed-world-surface.pmtiles + TIPPECANOE_VERSION: "2.79.0" + PMTILES_VERSION: "v1.31.2" + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - uses: actions/setup-go@v5 + with: + go-version: "1.24.x" + + - name: Install pinned PMTiles converter + run: | + set -euo pipefail + go install "github.com/protomaps/go-pmtiles@${PMTILES_VERSION}" + "$(go env GOPATH)/bin/go-pmtiles" version + + - name: Build consolidated zoom 0-5 overview + run: | + set -euo pipefail + mkdir -p virtual-world + node scripts/build-world-overview.mjs \ + --manifest "https://github.com/${GITHUB_REPOSITORY}/releases/download/${WORLD_RELEASE_TAG}/world-manifest.json" \ + --release-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${WORLD_RELEASE_TAG}" \ + --output virtual-world/overview-tiles \ + --maxzoom 5 \ + --concurrency 24 + python3 scripts/pack-mbtiles.py \ + --tiles virtual-world/overview-tiles \ + --metadata virtual-world/overview-tiles/metadata.json \ + --output virtual-world/overview.mbtiles + "$(go env GOPATH)/bin/go-pmtiles" convert \ + virtual-world/overview.mbtiles \ + "virtual-world/${OVERVIEW_ASSET}" + + - name: Build worldwide land surface + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential libsqlite3-dev zlib1g-dev + git clone --depth 1 --branch "${TIPPECANOE_VERSION}" \ + https://github.com/felt/tippecanoe.git virtual-world/tippecanoe + make -C virtual-world/tippecanoe -j2 + curl --fail --location --retry 5 \ + --output virtual-world/ne_10m_land.geojson \ + https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_land.geojson + virtual-world/tippecanoe/tippecanoe \ + --force \ + --minimum-zoom=0 \ + --maximum-zoom=10 \ + --layer=land \ + --no-feature-limit \ + --no-tile-size-limit \ + --detect-shared-borders \ + --output="virtual-world/${SURFACE_ASSET}" \ + virtual-world/ne_10m_land.geojson + + - name: Validate virtual archives + run: | + set -euo pipefail + for asset in "$OVERVIEW_ASSET" "$SURFACE_ASSET"; do + test -s "virtual-world/$asset" + test "$(head -c 7 "virtual-world/$asset")" = "PMTiles" + "$(go env GOPATH)/bin/go-pmtiles" show "virtual-world/$asset" --header-json + done + + - name: Publish virtual storage archives + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$WORLD_RELEASE_TAG" \ + "virtual-world/${OVERVIEW_ASSET}" \ + "virtual-world/${SURFACE_ASSET}" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + - name: Publish server-only routing manifest + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p virtual-world/manifest + node scripts/plan-world-shards-final.mjs > virtual-world/manifest/world-plan.json + release_id="$(gh api "/repos/${GITHUB_REPOSITORY}/releases/tags/${WORLD_RELEASE_TAG}" --jq '.id')" + gh api --paginate --slurp \ + "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \ + > virtual-world/manifest/release-pages.json + node scripts/build-world-manifest.mjs \ + --plan virtual-world/manifest/world-plan.json \ + --asset-pages virtual-world/manifest/release-pages.json \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$WORLD_RELEASE_TAG" \ + --overview-asset "$OVERVIEW_ASSET" \ + --surface-asset "$SURFACE_ASSET" \ + --output virtual-world/manifest/world-virtual-manifest.json + gh release upload "$WORLD_RELEASE_TAG" \ + virtual-world/manifest/world-virtual-manifest.json \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + - name: Smoke-test worldwide gateway coverage + run: | + set -euo pipefail + node scripts/check-live-world-tiles.mjs \ + --manifest "https://github.com/${GITHUB_REPOSITORY}/releases/download/${WORLD_RELEASE_TAG}/world-virtual-manifest.json" \ + --release-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${WORLD_RELEASE_TAG}" + + - uses: actions/upload-artifact@v4 + with: + name: occumed-virtual-world-${{ github.sha }} + path: | + virtual-world/occumed-world-overview.pmtiles + virtual-world/occumed-world-surface.pmtiles + virtual-world/manifest/world-virtual-manifest.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/clean-world-release.yml b/.github/workflows/clean-world-release.yml index a8e88e78..6b1da753 100644 --- a/.github/workflows/clean-world-release.yml +++ b/.github/workflows/clean-world-release.yml @@ -80,15 +80,22 @@ jobs: const pages = JSON.parse(fs.readFileSync('release-cleanup/release-asset-pages-before.json', 'utf8')); const releaseAssets = pages.flatMap((page) => page || []); const canonicalNames = new Set(plan.include.map((region) => region.asset_name)); - const retained = releaseAssets.filter((asset) => canonicalNames.has(asset.name) && asset.size > 0); + const protectedNames = new Set([ + ...canonicalNames, + 'world-manifest.json', + 'world-virtual-manifest.json', + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles' + ]); + const retained = releaseAssets.filter((asset) => protectedNames.has(asset.name) && asset.size > 0); const deleteTargets = releaseAssets - .filter((asset) => !canonicalNames.has(asset.name) || asset.size <= 0) + .filter((asset) => !protectedNames.has(asset.name) || asset.size <= 0) .map((asset) => ({ id: asset.id, name: asset.name, sizeBytes: asset.size, - reason: canonicalNames.has(asset.name) - ? 'zero_size_canonical_pmtiles' + reason: protectedNames.has(asset.name) + ? 'zero_size_world_tileset_asset' : (asset.name.endsWith('.pmtiles') ? 'noncanonical_pmtiles' : 'noncanonical_release_asset') })); fs.writeFileSync( @@ -159,7 +166,14 @@ jobs: const pages = JSON.parse(fs.readFileSync('release-cleanup/release-asset-pages-after.json', 'utf8')); const assets = pages.flatMap((page) => page || []); const canonicalNames = new Set(plan.include.map((region) => region.asset_name)); - const unexpected = assets.filter((asset) => !canonicalNames.has(asset.name) || asset.size <= 0); + const protectedNames = new Set([ + ...canonicalNames, + 'world-manifest.json', + 'world-virtual-manifest.json', + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles' + ]); + const unexpected = assets.filter((asset) => !protectedNames.has(asset.name) || asset.size <= 0); if (unexpected.length) { throw new Error(`Cleanup left ${unexpected.length} unexpected or zero-byte release assets.`); } diff --git a/.github/workflows/finish-world-pmtiles.yml b/.github/workflows/finish-world-pmtiles.yml index 6a0e5fc8..f2f9bc44 100644 --- a/.github/workflows/finish-world-pmtiles.yml +++ b/.github/workflows/finish-world-pmtiles.yml @@ -153,8 +153,10 @@ jobs: node scripts/build-world-manifest.mjs \ --plan world-plan/world-plan.json \ --asset-pages world-release/asset-pages.json \ - --output world-release/world-manifest.json - missing="$(node -e "const m=require('./world-release/world-manifest.json'); process.stdout.write(String(m.missingRegions?.length ?? -1))")" + --repository "$GITHUB_REPOSITORY" \ + --tag "$WORLD_RELEASE_TAG" \ + --output world-release/world-virtual-manifest.json + missing="$(node -e "const m=require('./world-release/world-virtual-manifest.json'); process.stdout.write(String(m.missingRegions?.length ?? -1))")" echo "missing_shards=$missing" if [ "$missing" != "0" ]; then exit 1 @@ -163,5 +165,5 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release upload "$WORLD_RELEASE_TAG" world-release/world-manifest.json \ + gh release upload "$WORLD_RELEASE_TAG" world-release/world-virtual-manifest.json \ --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/inventory-world-release.yml b/.github/workflows/inventory-world-release.yml index d4b9684d..a22d2b17 100644 --- a/.github/workflows/inventory-world-release.yml +++ b/.github/workflows/inventory-world-release.yml @@ -75,8 +75,14 @@ jobs: const published = include.filter((region) => byName.has(region.asset_name)); const missing = include.filter((region) => !byName.has(region.asset_name)); const canonicalNames = new Set(assetNames); + const virtualAssetNames = new Set([ + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles' + ]); const extraPmtiles = releaseAssets.filter((asset) => - asset.name.endsWith('.pmtiles') && !canonicalNames.has(asset.name) + asset.name.endsWith('.pmtiles') && + !canonicalNames.has(asset.name) && + !virtualAssetNames.has(asset.name) ); const publishedBytes = published.reduce((sum, region) => sum + Number(byName.get(region.asset_name)?.size || 0), 0); const zeroSize = published.filter((region) => Number(byName.get(region.asset_name)?.size || 0) <= 0); diff --git a/.github/workflows/repair-missing-world-pmtiles.yml b/.github/workflows/repair-missing-world-pmtiles.yml index 4499f2ac..c4d045af 100644 --- a/.github/workflows/repair-missing-world-pmtiles.yml +++ b/.github/workflows/repair-missing-world-pmtiles.yml @@ -49,7 +49,13 @@ jobs: const plan = JSON.parse(fs.readFileSync('world-plan/world-plan.json', 'utf8')); const pages = JSON.parse(fs.readFileSync('world-plan/release-pages-before-cleanup.json', 'utf8')); const assets = pages.flatMap((page) => page || []); - const keep = new Set(['world-manifest.json', ...(plan.include || []).map((region) => region.asset_name)]); + const keep = new Set([ + 'world-manifest.json', + 'world-virtual-manifest.json', + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles', + ...(plan.include || []).map((region) => region.asset_name) + ]); const deletions = assets.filter((asset) => !keep.has(asset.name) || (asset.name !== 'world-manifest.json' && Number(asset.size || 0) <= 0)); fs.writeFileSync('/tmp/delete-release-assets.tsv', deletions.map((asset) => `${asset.id}\t${asset.name}\t${asset.size || 0}`).join('\n') + (deletions.length ? '\n' : '')); console.log(`Pinned canonical shard count: ${plan.include.length}`); @@ -163,9 +169,9 @@ jobs: --assets world-release/assets.json \ --repository "$GITHUB_REPOSITORY" \ --tag "$WORLD_RELEASE_TAG" \ - --output world-release/world-manifest.json - missing="$(node -e "const m=require('./world-release/world-manifest.json'); process.stdout.write(String(m.missingRegionCount))")" + --output world-release/world-virtual-manifest.json + missing="$(node -e "const m=require('./world-release/world-virtual-manifest.json'); process.stdout.write(String(m.missingRegionCount))")" echo "missing_shards=$missing" test "$missing" = "0" - gh release upload "$WORLD_RELEASE_TAG" world-release/world-manifest.json \ + gh release upload "$WORLD_RELEASE_TAG" world-release/world-virtual-manifest.json \ --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/repair-world-pmtiles.yml b/.github/workflows/repair-world-pmtiles.yml index fc3cb695..7ed525cf 100644 --- a/.github/workflows/repair-world-pmtiles.yml +++ b/.github/workflows/repair-world-pmtiles.yml @@ -313,19 +313,19 @@ jobs: --assets world-release/assets.json \ --repository "$GITHUB_REPOSITORY" \ --tag "$WORLD_RELEASE_TAG" \ - --output world-release/occumed-world-manifest.json + --output world-release/world-virtual-manifest.json gh release upload "$WORLD_RELEASE_TAG" \ - world-release/occumed-world-manifest.json \ + world-release/world-virtual-manifest.json \ --repo "$GITHUB_REPOSITORY" \ --clobber - cat world-release/occumed-world-manifest.json + cat world-release/world-virtual-manifest.json - name: Require zero missing worldwide shards run: | - test -s world-release/occumed-world-manifest.json - missing="$(node -p "require('./world-release/occumed-world-manifest.json').missingRegionCount")" + test -s world-release/world-virtual-manifest.json + missing="$(node -p "require('./world-release/world-virtual-manifest.json').missingRegionCount")" if [ "$missing" -ne 0 ]; then echo "$missing worldwide PMTiles shards are still missing from the release." >&2 exit 1 @@ -336,6 +336,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: occumed-world-manifest-${{ github.sha }} - path: world-release/occumed-world-manifest.json + path: world-release/world-virtual-manifest.json if-no-files-found: error - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/README.md b/README.md index 284daecb..58f0bc85 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ A reusable Occu-Med basemap that uses the uploaded **Occu-Med Terrain** export a The supplied Mapbox Studio screenshots are the visual acceptance reference for: - globe scale, black space, and the white-blue atmospheric rim; -- vivid oceans and visible bathymetry; -- pale land, forest, grassland, wetland, agriculture, desert, snow, and urban separation; +- clear blue oceans; +- saturated green land with forest, grassland, wetland, agriculture, desert, snow, and urban separation; - terrain relief and contours; - road, boundary, place-label, water-label, and POI hierarchy; - globe, regional, city, and street zoom transitions. @@ -18,14 +18,29 @@ The goal is a close visual replica without a Mapbox token or Mapbox-hosted runti ## Runtime - MapLibre GL JS -- the worldwide Occu-Med Planetiler + PMTiles source when a regional archive is available -- a safe global open-vector fallback for low zoom and missing archives -- open terrain and relief services +- one permanent MapLibre vector source at `/tiles/{z}/{x}/{y}.pbf` +- a server-side virtual worldwide tileset backed by the 754 PMTiles storage archives +- one consolidated zoom 0–5 overview and one generalized worldwide land surface +- open elevation data used only for hillshade, not as a second basemap - locally compiled sprites -- open glyphs +- browser-local glyph rendering - generated runtime style at `/style/occumed-open.json` -No `VITE_MAPBOX_ACCESS_TOKEN`, `mapbox-gl`, `mapbox://` URL, or Mapbox API endpoint is required by the active build. +No `VITE_MAPBOX_ACCESS_TOKEN`, `mapbox-gl`, `mapbox://` URL, Mapbox API endpoint, OpenFreeMap source, or external vector fallback is used by the active build. + +## Worldwide tile architecture + +MapLibre sees only `occumed-open`, whose URL is permanent from zoom 0 through 16. The browser does not load the world manifest, select an archive, register a PMTiles protocol, or replace a source while the map moves. + +The Node tile gateway resolves each Z/X/Y request on the server: + +- zoom 0–5 comes from a consolidated overview built from the same regional schema; +- zoom 6–16 is resolved against every storage shard intersecting the requested tile; +- boundary tiles are decoded, deduplicated by stable feature ID, and re-encoded as one MVT; +- the worldwide `land` layer is merged into the same response at every zoom; +- completed virtual tiles are held in a bounded in-memory cache and exposed with CDN cache headers. + +The PMTiles archives and routing manifest are storage implementation details. Their URLs never appear in the MapLibre style. ## Source of truth @@ -64,7 +79,7 @@ Install the repository in the consuming application: npm install github:Occumed79/Map ``` -Create the map through the shared helper rather than constructing a raw MapLibre instance from the style URL. The helper registers the PMTiles protocol, resolves the deployed origin, installs worldwide archive routing, and preserves application-owned overlays. +Create the map through the shared helper to retain the approved high-DPI, no-fade rendering defaults while preserving application-owned overlays. ```js import { createOccumedMap } from '@occumed/map/src/occumed-map.js'; @@ -79,14 +94,8 @@ const map = await createOccumedMap({ map.on('load', () => { // Add only this application's overlays here. }); - -map.on('occumedworldsourcechange', (event) => { - console.debug('Occu-Med regional basemap source', event.region || 'global fallback'); -}); ``` -Directly fetching the style and passing it to a separate `new maplibregl.Map(...)` instance is not supported for worldwide mode because that bypasses PMTiles protocol registration and regional source routing. - ## Validation The build verifies: @@ -95,5 +104,9 @@ The build verifies: - the generated style passes the MapLibre style specification; - no active source, sprite, or glyph URL points to Mapbox; - globe, terrain, landcover, water, labels, and viewer-quality settings remain calibrated to the screenshot reference set; -- worldwide PMTiles planning, release publication, byte-range proxying, and regional source switching remain wired; +- only one permanent vector source and one same-origin Z/X/Y template exist in the style; +- browser-side PMTiles routing, `source.setUrl()`, fallback URLs, and OpenFreeMap are absent; +- the routing index includes every intersecting shard, including antimeridian segments; +- duplicate features are removed while boundary geometry is preserved; +- the overview, land surface, regional merge, in-memory cache, and virtual release workflow remain wired; - no application-specific overlay data is included. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..10643a37 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1820 @@ +{ + "name": "@occumed/map", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@occumed/map", + "version": "0.4.0", + "dependencies": { + "@mapbox/vector-tile": "2.0.4", + "maplibre-gl": "5.24.0", + "pbf": "4.0.1", + "pmtiles": "4.4.1", + "vt-pbf": "3.1.3" + }, + "devDependencies": { + "@chrispahm/spritezero": "8.1.0", + "@maplibre/maplibre-gl-style-spec": "24.8.1", + "vite": "8.1.5" + } + }, + "node_modules/@chrispahm/spritezero": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@chrispahm/spritezero/-/spritezero-8.1.0.tgz", + "integrity": "sha512-ZJ6PzZWcULuiHYQQ2temELn7T1EHkHYZ4Fyji8Cead1Qt6Uww1teQsE2AOMEG5CkTgHlsYjXsm6PxxDgVBXtZw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@mapbox/shelf-pack": "~3.0.0", + "@mapnik/mapnik": "^4.7.7", + "queue-async": "^1.2.1", + "svg-boundings": "^2.0.3", + "svgo": "^2.8.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/shelf-pack": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/shelf-pack/-/shelf-pack-3.0.0.tgz", + "integrity": "sha512-zNg5cvVoLx9zigv6w0OTCxoB06b0bq7ozHU/+qW+XUsHW3akYRCvYS26ZTCxec3Ca0pIdQ0ApPZMIS0vI0v4cQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz", + "integrity": "sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@mapnik/core-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@mapnik/core-darwin-arm64/-/core-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-bhNq8XSwFf+Q2HV+8ouoDnxpkDyyRI5s1MKh33cOPtYPoAQiTDsbfqy7GWrdVUiPakdJIpoKCx7Av32R6q7c6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@mapnik/core-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@mapnik/core-darwin-x64/-/core-darwin-x64-4.3.0.tgz", + "integrity": "sha512-ExLgyGH1sgdBp+NevJ0eZvKfLKgxz5ga6QfRtj49fZikXOwamMUQNcIzqal0p8qjUKQtiqnRuWWxNxQC7NxdgA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@mapnik/core-linux-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@mapnik/core-linux-arm64/-/core-linux-arm64-4.3.0.tgz", + "integrity": "sha512-S6U3aNHfJwXIS0i5Uu2HOGn9IotCoA5Cj20D4SGLMIJeBoBLadIQR9vl7u3+bLLVl3IXqEvhWeK5Hy7qQGv6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@mapnik/core-linux-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@mapnik/core-linux-x64/-/core-linux-x64-4.3.0.tgz", + "integrity": "sha512-ZxTL+DXG9ebUd6BINj9MW7jBmA/sP7P4eWoJO7wkYs3AxSczdk8ww8SytA7Ym8VfgVCjEvtS2rY5mlMguQioug==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@mapnik/mapnik": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@mapnik/mapnik/-/mapnik-4.8.0.tgz", + "integrity": "sha512-3Zir5n8b/xsTAc9fgUGxwqZiqql11B3CkTQlHjH+yFNIzAHx7Bmz82AfBgmSwEeXXSs1QqyFTuQB6jhWDwt4xQ==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4", + "prebuildify": "^6.0.1" + }, + "bin": { + "mapnik-index.js": "bin/mapnik-index.js", + "mapnik-inspect.js": "bin/mapnik-inspect.js", + "mapnik-render.js": "bin/mapnik-render.js", + "mapnik-shapeindex.js": "bin/mapnik-shapeindex.js" + }, + "optionalDependencies": { + "@mapnik/core-darwin-arm64": "4.3.0", + "@mapnik/core-darwin-x64": "4.3.0", + "@mapnik/core-linux-arm64": "4.3.0", + "@mapnik/core-linux-x64": "4.3.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pmtiles": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz", + "integrity": "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prebuildify": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/prebuildify/-/prebuildify-6.0.1.tgz", + "integrity": "sha512-8Y2oOOateom/s8dNBsGIcnm6AxPmLH4/nanQzL5lQMU+sC0CMhzARZHizwr36pUPLdvBnOkCNQzxg4djuFSgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "mkdirp-classic": "^0.5.3", + "node-abi": "^3.3.0", + "npm-run-path": "^3.1.0", + "pump": "^3.0.0", + "tar-fs": "^2.1.0" + }, + "bin": { + "prebuildify": "bin.js" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue-async": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/queue-async/-/queue-async-1.2.1.tgz", + "integrity": "sha512-aPVvlovU79BlPPgEiroL5XO57lZy1WrnyypfpGy25O+VQBbjOgr4XZYL/JVPuYN1zQO0oEH3bd1ZPRPO/nqZ5A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/svg-boundings": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/svg-boundings/-/svg-boundings-2.0.8.tgz", + "integrity": "sha512-9Vv80yJWGUEuGWcDDkYSMZuk8boD18+4tVX2KB0Xn+NwEP4j1mBsJKtZX6/qeULcwyycwDctTamsZm4qWj1EYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/svgo": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.3.tgz", + "integrity": "sha512-5EZD0pafXX6PphdwOGCiVLDSaV1xyuQao2blHajHLsPxr07q4mmEjdtXEWgG07ae2mIz8Ex2CDXNCTiXhy3Khw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "sax": "^1.5.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/vt-pbf/node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/vt-pbf/node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/vt-pbf/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index cf8afa8a..fbe4ef25 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "type": "module", "scripts": { "prepare:sprites": "node scripts/build-sprites.mjs", - "prepare:style": "node scripts/build-runtime-style.mjs && node scripts/normalize-runtime-fonts.mjs && node scripts/normalize-runtime-filters.mjs && node scripts/normalize-boundary-filters.mjs && node scripts/force-runtime-filter-expressions.mjs && node scripts/apply-schema-parity.mjs && node scripts/apply-globe-parity.mjs && node scripts/apply-photo-reference.mjs && node scripts/restore-exported-cartography.mjs && node scripts/calibrate-reference-colors.mjs && node scripts/lock-exact-exported-swatches.mjs && node scripts/lock-reference-atmosphere.mjs && node scripts/normalize-runtime-fonts.mjs && node scripts/use-local-maplibre-glyphs.mjs && node scripts/use-custom-pmtiles-source.mjs", + "prepare:style": "node scripts/build-runtime-style.mjs && node scripts/normalize-runtime-fonts.mjs && node scripts/normalize-runtime-filters.mjs && node scripts/normalize-boundary-filters.mjs && node scripts/force-runtime-filter-expressions.mjs && node scripts/apply-schema-parity.mjs && node scripts/apply-globe-parity.mjs && node scripts/apply-photo-reference.mjs && node scripts/restore-exported-cartography.mjs && node scripts/calibrate-reference-colors.mjs && node scripts/lock-exact-exported-swatches.mjs && node scripts/lock-reference-atmosphere.mjs && node scripts/normalize-runtime-fonts.mjs && node scripts/use-local-maplibre-glyphs.mjs", "prepare:assets": "npm run prepare:sprites && npm run prepare:style", "tiles:build": "bash planetiler/build-region.sh", "tiles:plan-world": "node scripts/plan-world-shards.mjs --scope all", "check:export": "node scripts/check-export.mjs", - "check:runtime": "node scripts/check-runtime.mjs && node scripts/validate-maplibre-style.mjs && node scripts/check-globe-parity.mjs && node scripts/check-cartography-parity.mjs && node scripts/check-viewer-quality.mjs && node scripts/check-photo-reference.mjs && node scripts/check-exact-exported-swatches.mjs && node scripts/check-render-clarity.mjs && node scripts/check-pmtiles-integration.mjs", + "check:runtime": "node scripts/check-runtime.mjs && node scripts/validate-maplibre-style.mjs && node scripts/check-globe-parity.mjs && node scripts/check-cartography-parity.mjs && node scripts/check-viewer-quality.mjs && node scripts/check-photo-reference.mjs && node scripts/check-exact-exported-swatches.mjs && node scripts/check-render-clarity.mjs && node scripts/check-pmtiles-integration.mjs && node scripts/check-world-tile-gateway.mjs", "check:server": "node scripts/check-server-health.mjs", "check": "npm run check:export && npm run check:runtime", "dev": "npm run prepare:assets && vite", @@ -19,8 +19,11 @@ "start": "node server.mjs" }, "dependencies": { + "@mapbox/vector-tile": "2.0.4", "maplibre-gl": "5.24.0", - "pmtiles": "4.4.1" + "pbf": "4.0.1", + "pmtiles": "4.4.1", + "vt-pbf": "3.1.3" }, "devDependencies": { "@chrispahm/spritezero": "8.1.0", diff --git a/planetiler/README.md b/planetiler/README.md index 98918822..9e556800 100644 --- a/planetiler/README.md +++ b/planetiler/README.md @@ -35,26 +35,24 @@ The workflow: 3. builds every shard on free GitHub-hosted runners; 4. publishes archives, checksums, and metadata to the `occumed-world-v1` GitHub Release; 5. validates byte-range delivery for every archive; -6. publishes `occumed-world-manifest.json` only from successfully uploaded assets; -7. fails until the manifest reports zero missing regions. +6. consolidates shard tiles at zoom 0–5 into `occumed-world-overview.pmtiles`; +7. builds a generalized worldwide `land` surface; +8. publishes a server-only routing manifest after every required archive exists. -The browser keeps the existing global overview at low zoom. Beginning at zoom 6, `src/world-pmtiles-router.js` selects the smallest matching regional archive and changes the shared vector source without replacing application overlays. - -The deployed Node server exposes: +The browser never selects or reads these archives. It uses one permanent vector template: ```text -/world-manifest.json -/world-tiles/occumed-.pmtiles +/tiles/{z}/{x}/{y}.pbf ``` -Those endpoints proxy the GitHub Release assets, preserve HTTP range requests, and add the required CORS and cache headers. No paid object-storage account is required. +The Node gateway uses the consolidated overview at zoom 0–5. At zoom 6–16 it looks up every regional archive intersecting the requested tile, reads the storage pieces, deduplicates stable feature IDs, merges each MVT layer, adds the worldwide land surface, and caches the completed tile. Shard boundaries and archive URLs are therefore invisible to MapLibre. -## Launch the worldwide build +## Launch the virtual worldwide build -The workflow can be started manually from GitHub Actions or by changing: +After the 754 regional archives are complete, run this workflow manually: ```text -planetiler/world-build.trigger +Build Virtual Worldwide Tileset ``` The release tag defaults to: @@ -63,13 +61,7 @@ The release tag defaults to: occumed-world-v1 ``` -The runtime automatically checks: - -```text -__OCCUMED_PUBLIC_ORIGIN__/world-manifest.json -``` - -Until the release manifest exists, the map safely retains the current global vector source. +It publishes `occumed-world-overview.pmtiles`, `occumed-world-surface.pmtiles`, and the version 2 server-only `world-virtual-manifest.json` to the release. The legacy `world-manifest.json` can remain during rollout because the new browser never requests either manifest. ## Build one regional archive locally @@ -94,22 +86,12 @@ OCCUMED_TILE_AREA=australia/new-south-wales npm run tiles:build An existing `.osm.pbf` can be used by setting `OCCUMED_OSM_PBF` to a file inside `OCCUMED_PLANETILER_DATA_DIR`. -## Single-archive override - -Worldwide routing is enabled by default. To disable it and use one archive instead: - -```bash -OCCUMED_WORLD_MANIFEST_URL=off \ -OCCUMED_PMTILES_URL=__OCCUMED_PUBLIC_ORIGIN__/tiles/occumed.pmtiles \ -npm run build -``` - -To use a different worldwide manifest: +To point the server at a compatible server-only manifest: ```bash -OCCUMED_WORLD_MANIFEST_URL=https://tiles.example.org/occumed-world-manifest.json npm run build +OCCUMED_WORLD_MANIFEST_URL=https://storage.example.org/world-virtual-manifest.json npm start ``` ## Remaining enrichment stages -The worldwide vector schema and delivery path are independent from terrain enrichment. Generated contours, GEBCO bathymetric bands, Overture enrichment, and complete route-relation shield metadata can be added as separate stages without changing the PMTiles routing architecture. +The worldwide vector schema and delivery path are independent from terrain enrichment. Generated contours, bathymetric bands, Overture enrichment, and complete route-relation shield metadata can be added to the virtual MVT response without changing the one permanent browser source. diff --git a/scripts/apply-globe-parity.mjs b/scripts/apply-globe-parity.mjs index b38a4f64..bfba9293 100644 --- a/scripts/apply-globe-parity.mjs +++ b/scripts/apply-globe-parity.mjs @@ -49,37 +49,6 @@ runtime.sky = { delete runtime.fog; delete runtime.light; -const relief = runtime.layers.find((layer) => layer.id === 'occumed-shaded-relief'); -if (!relief) throw new Error('The generated low-zoom relief layer is missing.'); - -relief.paint = { - 'raster-opacity': [ - 'interpolate', - ['exponential', 1.2], - ['zoom'], - 0, - 0.68, - 2.5, - 0.62, - 4.5, - 0.38, - 6.5, - 0 - ], - 'raster-saturation': 0.62, - 'raster-contrast': 0.12, - 'raster-brightness-min': 0.02, - 'raster-brightness-max': 0.94, - 'raster-resampling': 'linear', - 'raster-fade-duration': 0 -}; -relief.metadata = { - ...(relief.metadata || {}), - 'occumed:purpose': 'low-zoom landcover and bathymetry texture', - 'occumed:visual-reference': 'exported Occu-Med Terrain globe', - 'occumed:orientation-neutral': true -}; - const hillshade = runtime.layers.find((layer) => layer.id === 'occumed-hillshade'); if (!hillshade) throw new Error('The generated open hillshade layer is missing.'); @@ -111,4 +80,4 @@ runtime.metadata = { }; await fs.writeFile(runtimePath, `${JSON.stringify(runtime, null, 2)}\n`); -console.log('Applied refined Occu-Med globe rim, vivid low-zoom relief, and stable hillshade.'); +console.log('Applied the refined Occu-Med globe rim and stable hillshade.'); diff --git a/scripts/apply-photo-reference.mjs b/scripts/apply-photo-reference.mjs index 1f35fe6a..f58ffd69 100644 --- a/scripts/apply-photo-reference.mjs +++ b/scripts/apply-photo-reference.mjs @@ -96,32 +96,6 @@ for (const layer of runtime.layers || []) { continue; } - if (layer.id === 'occumed-shaded-relief') { - layer.maxzoom = 7; - layer.paint = { - 'raster-opacity': [ - 'interpolate', ['linear'], ['zoom'], - 0, 0.34, - 2.5, 0.31, - 4.5, 0.22, - 6.5, 0 - ], - 'raster-saturation': 0.72, - 'raster-contrast': 0.16, - 'raster-brightness-min': 0.02, - 'raster-brightness-max': 0.96, - 'raster-resampling': 'linear', - 'raster-fade-duration': 0 - }; - layer.metadata = { - ...(layer.metadata || {}), - 'occumed:purpose': 'photo-calibrated bathymetry and low-zoom relief', - 'occumed:reference': 'Mapbox Studio screenshot set supplied 2026-07-25', - 'occumed:live-qa-pass': 2 - }; - continue; - } - if (layer.id === 'occumed-hillshade') { layer.minzoom = 3; layer.maxzoom = 16; diff --git a/scripts/build-runtime-style.mjs b/scripts/build-runtime-style.mjs index bfee048c..4d8b6b94 100644 --- a/scripts/build-runtime-style.mjs +++ b/scripts/build-runtime-style.mjs @@ -8,16 +8,15 @@ const outputDir = path.join(root, 'public/style'); const outputPath = path.join(outputDir, 'occumed-open.json'); const reportPath = path.join(outputDir, 'compatibility-report.json'); -const tilejsonUrl = process.env.OCCUMED_TILEJSON_URL || 'https://tiles.openfreemap.org/planet'; +const vectorTilesUrl = + process.env.OCCUMED_VECTOR_TILES_URL || + '__OCCUMED_PUBLIC_ORIGIN__/tiles/{z}/{x}/{y}.pbf'; const glyphsUrl = process.env.OCCUMED_GLYPHS_URL || 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'; const terrainUrl = process.env.OCCUMED_TERRAIN_URL || 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'; -const reliefUrl = - process.env.OCCUMED_RELIEF_URL || - 'https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'; const original = JSON.parse(await fs.readFile(sourcePath, 'utf8')); @@ -295,28 +294,27 @@ function rewriteLayer(layer, targetSourceLayer) { const convertedLayers = []; const skippedLayers = []; const sourceLayerMappings = {}; -let reliefInserted = false; let hillshadeInserted = false; for (const layer of original.layers || []) { if (!layer.source) { convertedLayers.push(clone(layer)); - if (!reliefInserted && layer.type === 'background') { + if (layer.type === 'background' && !convertedLayers.some((candidate) => candidate.id === 'occumed-land-surface')) { convertedLayers.push({ - id: 'occumed-shaded-relief', - type: 'raster', - source: 'occumed-relief', - maxzoom: 7, + id: 'occumed-land-surface', + type: 'fill', + source: 'occumed-open', + 'source-layer': 'land', paint: { - 'raster-opacity': ['interpolate', ['linear'], ['zoom'], 0, 0.38, 3, 0.28, 7, 0], - 'raster-saturation': 0.08, - 'raster-contrast': 0.06, - 'raster-resampling': 'linear' + 'fill-color': '#8FB86B', + 'fill-opacity': 1, + 'fill-antialias': true }, - metadata: { 'occumed:purpose': 'low-zoom terrain definition' } + metadata: { + 'occumed:purpose': 'continuous worldwide land surface from the virtual tileset' + } }); - reliefInserted = true; } continue; } @@ -376,9 +374,11 @@ const runtimeStyle = { sources: { 'occumed-open': { type: 'vector', - url: tilejsonUrl, + tiles: [vectorTilesUrl], + minzoom: 0, + maxzoom: 16, attribution: - 'OpenFreeMap © OpenMapTiles · Data © OpenStreetMap contributors' + '© OpenStreetMap contributors' }, 'occumed-terrain': { type: 'raster-dem', @@ -388,14 +388,6 @@ const runtimeStyle = { minzoom: 0, maxzoom: 15, attribution: 'Elevation data via the AWS Terrain Tiles public dataset' - }, - 'occumed-relief': { - type: 'raster', - tiles: [reliefUrl], - tileSize: 256, - minzoom: 0, - maxzoom: 6, - attribution: 'Natural Earth shaded relief via OpenFreeMap' } }, sprite: '__OCCUMED_PUBLIC_ORIGIN__/sprites/occumed', @@ -415,10 +407,10 @@ const report = { Object.entries(sourceLayerMappings).map(([key, values]) => [key, [...values].sort()]) ), endpoints: { - vectorTiles: tilejsonUrl, + vectorTiles: vectorTilesUrl, glyphs: glyphsUrl, terrain: terrainUrl, - relief: reliefUrl, + relief: null, sprite: '__OCCUMED_PUBLIC_ORIGIN__/sprites/occumed' } }; diff --git a/scripts/build-world-manifest.mjs b/scripts/build-world-manifest.mjs index 23789a40..b71236b5 100644 --- a/scripts/build-world-manifest.mjs +++ b/scripts/build-world-manifest.mjs @@ -8,9 +8,12 @@ function parseArgs(argv) { const key = argv[index]; if (key === '--plan') options.plan = argv[++index]; else if (key === '--assets') options.assets = argv[++index]; + else if (key === '--asset-pages') options.assets = argv[++index]; else if (key === '--repository') options.repository = argv[++index]; else if (key === '--tag') options.tag = argv[++index]; else if (key === '--output') options.output = argv[++index]; + else if (key === '--overview-asset') options.overviewAsset = argv[++index]; + else if (key === '--surface-asset') options.surfaceAsset = argv[++index]; } for (const required of ['plan', 'assets', 'repository', 'tag', 'output']) { if (!options[required]) throw new Error(`Missing --${required}.`); @@ -24,22 +27,41 @@ const [plan, assetsPayload] = await Promise.all([ fs.readFile(options.assets, 'utf8').then(JSON.parse) ]); -const assetNames = new Set((assetsPayload.assets || []).map((asset) => asset.name)); +const assets = Array.isArray(assetsPayload) + ? assetsPayload.flatMap((page) => page || []) + : assetsPayload.assets || []; +const assetNames = new Set(assets.map((asset) => asset.name)); const planned = plan.include || []; const available = planned.filter((region) => assetNames.has(region.asset_name)); const missing = planned.filter((region) => !assetNames.has(region.asset_name)); -const releaseBase = `https://github.com/${options.repository}/releases/download/${options.tag}`; +const overviewAsset = options.overviewAsset || 'occumed-world-overview.pmtiles'; +const surfaceAsset = options.surfaceAsset || 'occumed-world-surface.pmtiles'; +const missingVirtualAssets = [overviewAsset, surfaceAsset].filter((asset) => !assetNames.has(asset)); + +if (missingVirtualAssets.length) { + throw new Error( + `Cannot publish the virtual worldwide manifest; missing ${missingVirtualAssets.join(', ')}.` + ); +} const manifest = { - version: 1, + version: 2, generatedAt: new Date().toISOString(), releaseTag: options.tag, sourceSchema: 'planetiler/occumed-basemap.yml', - switchZoom: 6, attribution: '© OpenStreetMap contributors', - archiveTransport: 'same-origin-release-proxy', - archiveProxyTemplate: '__OCCUMED_PUBLIC_ORIGIN__/world-tiles/{asset}', - releaseBase, + archiveTransport: 'server-side-release-storage', + virtualTiles: { + endpoint: '/tiles/{z}/{x}/{y}.pbf', + overviewAsset, + overviewMaxZoom: 5, + surfaceAsset, + surfaceLayer: 'land', + surfaceMaxZoom: 10, + routingZoom: 6, + minZoom: 0, + maxZoom: 16 + }, regions: available.map((region) => ({ id: region.id, slug: region.slug, @@ -47,8 +69,7 @@ const manifest = { continent: region.continent, bounds: [region.west, region.south, region.east, region.north], asset: region.asset_name, - url: `__OCCUMED_PUBLIC_ORIGIN__/world-tiles/${region.asset_name}`, - minzoom: 6, + minzoom: 0, maxzoom: 16 })), plannedRegionCount: planned.length, diff --git a/scripts/build-world-overview.mjs b/scripts/build-world-overview.mjs new file mode 100644 index 00000000..1338e578 --- /dev/null +++ b/scripts/build-world-overview.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { PMTiles, SharedPromiseCache } from 'pmtiles'; +import { mergeVectorTiles } from '../src/server/mvt.js'; +import { RetryingFetchSource } from '../src/server/pmtiles-source.js'; +import { WorldTileRoutingIndex } from '../src/server/world-tile-routing.js'; + +function parseArgs(argv) { + const options = { + maxZoom: 5, + concurrency: 24 + }; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (key === '--manifest') options.manifest = argv[++index]; + else if (key === '--release-base') options.releaseBase = argv[++index]; + else if (key === '--output') options.output = argv[++index]; + else if (key === '--maxzoom') options.maxZoom = Number(argv[++index]); + else if (key === '--concurrency') options.concurrency = Number(argv[++index]); + } + for (const required of ['manifest', 'releaseBase', 'output']) { + if (!options[required]) throw new Error(`Missing --${required}.`); + } + if (!Number.isSafeInteger(options.maxZoom) || options.maxZoom < 0 || options.maxZoom > 8) { + throw new Error('--maxzoom must be an integer between 0 and 8.'); + } + if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 64) { + throw new Error('--concurrency must be an integer between 1 and 64.'); + } + return options; +} + +async function mapLimit(values, concurrency, mapper) { + const results = new Array(values.length); + let nextIndex = 0; + async function worker() { + while (nextIndex < values.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(values[index], index); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, () => worker()) + ); + return results; +} + +const options = parseArgs(process.argv.slice(2)); +const manifestResponse = await fetch(options.manifest, { + headers: { 'User-Agent': 'Occu-Med-Map/world-overview-builder' }, + redirect: 'follow' +}); +if (!manifestResponse.ok) { + throw new Error(`Unable to load worldwide manifest (${manifestResponse.status}).`); +} + +const manifest = await manifestResponse.json(); +if (!Array.isArray(manifest.regions) || !manifest.regions.length) { + throw new Error('Worldwide manifest has no regional archives.'); +} + +await fs.rm(options.output, { recursive: true, force: true }); +await fs.mkdir(options.output, { recursive: true }); + +const routing = new WorldTileRoutingIndex(manifest.regions, { + routingZoom: manifest.virtualTiles?.routingZoom || 6 +}); +const directoryCache = new SharedPromiseCache(8192); +const archives = new Map(); +const releaseBase = options.releaseBase.replace(/\/$/, ''); + +function archive(region) { + let instance = archives.get(region.asset); + if (!instance) { + instance = new PMTiles( + new RetryingFetchSource( + `${releaseBase}/${encodeURIComponent(region.asset)}`, + { attempts: 5, timeoutMs: 30_000 } + ), + directoryCache + ); + archives.set(region.asset, instance); + } + return instance; +} + +let writtenTiles = 0; +let sourceTileReads = 0; +for (let zoom = 0; zoom <= options.maxZoom; zoom += 1) { + const count = 2 ** zoom; + for (let x = 0; x < count; x += 1) { + for (let y = 0; y < count; y += 1) { + const regions = routing.regionsForTile(zoom, x, y); + const payloads = await mapLimit(regions, options.concurrency, async (region) => { + const result = await archive(region).getZxy(zoom, x, y); + sourceTileReads += 1; + return result?.data ? Buffer.from(result.data) : null; + }); + const merged = mergeVectorTiles(payloads); + if (!merged.byteLength) continue; + + const directory = path.join(options.output, String(zoom), String(x)); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, `${y}.pbf`), merged); + writtenTiles += 1; + } + } + console.log( + `Overview zoom ${zoom}: ${writtenTiles} merged tiles written from ${sourceTileReads} shard tile reads.` + ); +} + +const metadataRegion = manifest.regions[0]; +const metadata = await archive(metadataRegion).getMetadata(); +await fs.writeFile( + path.join(options.output, 'metadata.json'), + `${JSON.stringify({ + name: 'Occu-Med Worldwide Overview', + description: 'Consolidated low-zoom tiles from the Occu-Med regional storage shards', + version: '1', + type: 'baselayer', + format: 'pbf', + minzoom: 0, + maxzoom: options.maxZoom, + bounds: '-180,-85.0511288,180,85.0511288', + center: `0,0,${Math.min(2, options.maxZoom)}`, + vector_layers: metadata.vector_layers || [] + }, null, 2)}\n` +); + +console.log( + `Worldwide overview complete: ${writtenTiles} tiles, ${sourceTileReads} shard reads, zoom 0-${options.maxZoom}.` +); diff --git a/scripts/calibrate-reference-colors.mjs b/scripts/calibrate-reference-colors.mjs index a5f94485..165c7a53 100644 --- a/scripts/calibrate-reference-colors.mjs +++ b/scripts/calibrate-reference-colors.mjs @@ -127,42 +127,8 @@ for (const candidate of runtime.layers || []) { } runtime.sky = normalizeColorLiterals(runtime.sky || {}); -// The previous pass made the relief monochrome and almost invisible, leaving the -// exported pale land background to dominate the whole globe. Keep enough of the -// open relief's geographic color to restore green mountain belts, warm dry land, -// and physical depth, but keep saturation and opacity far below the fluorescent -// build that failed visual QA. -const relief = layer('occumed-shaded-relief'); -if (!relief) throw new Error('The open shaded-relief layer is missing.'); -relief.maxzoom = 8.5; -relief.paint = { - 'raster-opacity': [ - 'interpolate', - ['linear'], - ['zoom'], - 0, 0.32, - 2.5, 0.28, - 4.5, 0.2, - 6.5, 0.1, - 8.5, 0 - ], - 'raster-saturation': 0.12, - 'raster-contrast': 0.09, - 'raster-hue-rotate': -6, - 'raster-brightness-min': 0.08, - 'raster-brightness-max': 0.96, - 'raster-resampling': 'linear', - 'raster-fade-duration': 0 -}; -relief.metadata = { - ...(relief.metadata || {}), - 'occumed:purpose': 'restrained colored physical relief beneath the exported per-structure palette', - 'occumed:reference-color-pass': 7 -}; - // Preserve each exported landcover color, but make those separate colors visible -// at globe and regional zooms instead of allowing one beige background to cover -// the continent. No fill-color is replaced here. +// at globe and regional zooms. No fill-color is replaced here. const land = layer('land'); if (land?.paint) land.paint['background-opacity'] = 1; diff --git a/scripts/capture-pmtiles-preview.mjs b/scripts/capture-pmtiles-preview.mjs deleted file mode 100644 index 3022b626..00000000 --- a/scripts/capture-pmtiles-preview.mjs +++ /dev/null @@ -1,182 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { chromium } from 'playwright'; - -const origin = process.env.OCCUMED_PREVIEW_ORIGIN || 'http://127.0.0.1:4173'; -const outputDir = path.resolve(process.env.OCCUMED_PREVIEW_OUTPUT || 'artifacts/visual-preview'); -const expectedArchive = process.env.OCCUMED_PREVIEW_ARCHIVE || 'occumed-fresno.pmtiles'; -await fs.mkdir(outputDir, { recursive: true }); - -function sourceUrl(source) { - return typeof source?.url === 'string' ? source.url : ''; -} - -function assertHealthyView(name, view) { - const url = sourceUrl(view.source); - if (!url.startsWith('pmtiles://')) { - throw new Error(`${name} did not use the PMTiles protocol: ${url || 'missing source URL'}`); - } - if (!url.includes(expectedArchive)) { - throw new Error(`${name} used the wrong PMTiles archive: ${url}`); - } - if (!view.loaded) throw new Error(`${name} did not reach map.loaded().`); - if (!view.tilesLoaded) throw new Error(`${name} did not reach map.areTilesLoaded().`); - if (view.canvas.effectivePixelRatio < 1.9) { - throw new Error(`${name} rendered below the required effective 2x pixel ratio.`); - } -} - -const browser = await chromium.launch({ headless: true }); -try { - const context = await browser.newContext({ - viewport: { width: 1440, height: 1000 }, - deviceScaleFactor: 2, - colorScheme: 'dark' - }); - const page = await context.newPage(); - const consoleMessages = []; - const pageErrors = []; - const networkFailures = []; - - page.on('console', (message) => { - consoleMessages.push({ type: message.type(), text: message.text() }); - }); - page.on('pageerror', (error) => { - pageErrors.push(error.message); - }); - page.on('requestfailed', (request) => { - networkFailures.push({ - type: 'requestfailed', - url: request.url(), - method: request.method(), - error: request.failure()?.errorText || 'unknown request failure' - }); - }); - page.on('response', (response) => { - if (response.status() < 400) return; - networkFailures.push({ - type: 'http', - url: response.url(), - method: response.request().method(), - status: response.status() - }); - }); - - await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 60_000 }); - await page.waitForFunction( - () => globalThis.__OCCUMED_MAP__ && globalThis.__OCCUMED_MAP__.isStyleLoaded(), - null, - { timeout: 60_000 } - ); - - async function capture(name, center, zoom) { - await page.evaluate( - async ({ center: nextCenter, zoom: nextZoom }) => { - const map = globalThis.__OCCUMED_MAP__; - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) return; - settled = true; - resolve(); - }; - map.once('idle', finish); - map.jumpTo({ center: nextCenter, zoom: nextZoom, pitch: 0, bearing: 0 }); - setTimeout(finish, 20_000); - }); - }, - { center, zoom } - ); - - await page.waitForFunction( - () => { - const map = globalThis.__OCCUMED_MAP__; - return Boolean(map?.loaded() && map.areTilesLoaded()); - }, - null, - { timeout: 60_000 } - ); - - const view = await page.evaluate(() => { - const map = globalThis.__OCCUMED_MAP__; - const canvas = map.getCanvas(); - const rect = canvas.getBoundingClientRect(); - const source = map.getStyle().sources?.['occumed-open'] || null; - return { - center: map.getCenter().toArray(), - zoom: map.getZoom(), - source, - canvas: { - cssWidth: rect.width, - cssHeight: rect.height, - backingWidth: canvas.width, - backingHeight: canvas.height, - effectivePixelRatio: rect.width ? canvas.width / rect.width : null - }, - loaded: map.loaded(), - tilesLoaded: map.areTilesLoaded(), - visibleLayers: map - .getStyle() - .layers.filter((layer) => map.getLayoutProperty(layer.id, 'visibility') !== 'none') - .map((layer) => layer.id) - }; - }); - - assertHealthyView(name, view); - await page.screenshot({ - path: path.join(outputDir, `${name}.png`), - fullPage: false - }); - return view; - } - - const views = {}; - views.fresnoRegional = await capture('fresno-regional-z8', [-119.55, 36.75], 8); - views.fresnoCity = await capture('fresno-city-z11', [-119.7871, 36.7378], 11); - views.fresnoStreet = await capture('fresno-street-z14', [-119.7871, 36.7378], 14); - views.sierraTerrain = await capture('sierra-terrain-z10', [-118.85, 36.85], 10); - - const consoleFailures = consoleMessages.filter( - (entry) => entry.type === 'error' || /failed|error|404|416/i.test(entry.text) - ); - const allowedFailure = (entry) => { - const url = entry.url || entry.text || ''; - return /\/favicon\.ico(?:$|\?)/i.test(url); - }; - const unexpectedNetworkFailures = networkFailures.filter((entry) => !allowedFailure(entry)); - const unexpectedConsoleFailures = consoleFailures.filter((entry) => !allowedFailure(entry)); - - const report = { - generatedAt: new Date().toISOString(), - origin, - browser: 'chromium', - expectedArchive, - viewport: { width: 1440, height: 1000, deviceScaleFactor: 2 }, - views, - pageErrors, - networkFailures, - consoleFailures, - unexpectedNetworkFailures, - unexpectedConsoleFailures, - consoleMessages - }; - - await fs.writeFile( - path.join(outputDir, 'preview-report.json'), - `${JSON.stringify(report, null, 2)}\n` - ); - - if (pageErrors.length) { - throw new Error(`Browser page errors: ${pageErrors.join('; ')}`); - } - if (unexpectedNetworkFailures.length) { - throw new Error(`Unexpected browser resource failures: ${JSON.stringify(unexpectedNetworkFailures)}`); - } - if (unexpectedConsoleFailures.length) { - throw new Error(`Unexpected browser console failures: ${JSON.stringify(unexpectedConsoleFailures)}`); - } - - console.log(`Captured ${Object.keys(views).length} healthy custom PMTiles browser views in ${outputDir}.`); -} finally { - await browser.close(); -} diff --git a/scripts/capture-world-fresno-preview.mjs b/scripts/capture-world-fresno-preview.mjs index eaeba3d7..e65669c9 100644 --- a/scripts/capture-world-fresno-preview.mjs +++ b/scripts/capture-world-fresno-preview.mjs @@ -3,69 +3,11 @@ import path from 'node:path'; import { chromium } from 'playwright'; const origin = (process.env.OCCUMED_PREVIEW_ORIGIN || 'http://127.0.0.1:4173').replace(/\/$/, ''); -const outputDir = path.resolve(process.env.OCCUMED_PREVIEW_OUTPUT || 'visual-validation/fresno-world-final'); +const outputDir = path.resolve( + process.env.OCCUMED_PREVIEW_OUTPUT || 'visual-validation/continuous-world-final' +); await fs.mkdir(outputDir, { recursive: true }); -function containsCoordinate(bounds, longitude, latitude) { - if (!Array.isArray(bounds) || bounds.length !== 4) return false; - const [west, south, east, north] = bounds.map(Number); - if (![west, south, east, north].every(Number.isFinite)) return false; - const latitudeMatch = latitude >= south && latitude <= north; - const longitudeMatch = west <= east - ? longitude >= west && longitude <= east - : longitude >= west || longitude <= east; - return latitudeMatch && longitudeMatch; -} - -function boundsArea(bounds) { - const [west, south, east, north] = bounds.map(Number); - const width = west <= east ? east - west : 360 - west + east; - return Math.max(width, 0) * Math.max(north - south, 0); -} - -function selectRegion(regions, longitude, latitude) { - return regions - .filter((region) => containsCoordinate(region.bounds, longitude, latitude)) - .sort((left, right) => boundsArea(left.bounds) - boundsArea(right.bounds))[0] || null; -} - -const manifestResponse = await fetch(`${origin}/world-manifest.json`, { - headers: { 'Cache-Control': 'no-cache' } -}); -if (!manifestResponse.ok) { - throw new Error(`Worldwide manifest proxy failed with ${manifestResponse.status}.`); -} -const manifest = await manifestResponse.json(); -if (!Array.isArray(manifest.regions) || !manifest.regions.length) { - throw new Error('Worldwide manifest contains no regions.'); -} -if (Number(manifest.missingRegionCount) !== 0) { - throw new Error(`Worldwide manifest still reports ${manifest.missingRegionCount} missing regions.`); -} - -const rangeChecks = new Map(); -async function verifyRange(asset) { - if (rangeChecks.has(asset)) return rangeChecks.get(asset); - const response = await fetch(`${origin}/world-tiles/${encodeURIComponent(asset)}`, { - headers: { Range: 'bytes=0-126' }, - redirect: 'follow' - }); - const bytes = new Uint8Array(await response.arrayBuffer()); - const magic = new TextDecoder().decode(bytes.slice(0, 7)); - const result = { - asset, - status: response.status, - returnedBytes: bytes.length, - magic, - contentRange: response.headers.get('content-range') - }; - if (response.status !== 206 || bytes.length !== 127 || magic !== 'PMTiles') { - throw new Error(`Runtime range proxy failed for ${asset}: ${JSON.stringify(result)}`); - } - rangeChecks.set(asset, result); - return result; -} - const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ @@ -77,11 +19,20 @@ try { const consoleMessages = []; const pageErrors = []; const networkFailures = []; + const vectorRequests = []; + const forbiddenRequests = []; page.on('console', (message) => { consoleMessages.push({ type: message.type(), text: message.text() }); }); page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('request', (request) => { + const url = request.url(); + if (/openfreemap|\/world-tiles\/|\/world-manifest\.json/i.test(url)) { + forbiddenRequests.push(url); + } + if (/\.pbf(?:$|\?)/i.test(url)) vectorRequests.push(url); + }); page.on('requestfailed', (request) => { networkFailures.push({ type: 'requestfailed', @@ -107,11 +58,18 @@ try { { timeout: 90_000 } ); - async function capture(name, center, zoom) { - const expectedRegion = selectRegion(manifest.regions, center[0], center[1]); - if (!expectedRegion) throw new Error(`${name} has no worldwide shard in the manifest.`); - await verifyRange(expectedRegion.asset); + const initialSource = await page.evaluate(() => { + return globalThis.__OCCUMED_MAP__.getStyle().sources?.['occumed-open'] || null; + }); + const expectedTemplate = `${origin}/tiles/{z}/{x}/{y}.pbf`; + if ( + initialSource?.url || + JSON.stringify(initialSource?.tiles || []) !== JSON.stringify([expectedTemplate]) + ) { + throw new Error(`The browser source is not permanent: ${JSON.stringify(initialSource)}`); + } + async function capture(name, center, zoom) { await page.evaluate( ({ nextCenter, nextZoom }) => { const map = globalThis.__OCCUMED_MAP__; @@ -122,72 +80,52 @@ try { ); await page.waitForFunction( - ({ expectedAsset }) => { + () => { const map = globalThis.__OCCUMED_MAP__; - const source = map?.getStyle()?.sources?.['occumed-open']; - if ( - !map?.isStyleLoaded() || - typeof source?.url !== 'string' || - !source.url.startsWith('pmtiles://') || - !source.url.includes(`/world-tiles/${expectedAsset}`) - ) { - return false; - } - - try { - return map.queryRenderedFeatures().some((feature) => feature.source === 'occumed-open'); - } catch { - return false; - } + return map?.isStyleLoaded() && map.areTilesLoaded(); }, - { expectedAsset: expectedRegion.asset }, + null, { timeout: 90_000 } ); - - await page.waitForTimeout(750); + await page.waitForTimeout(300); const view = await page.evaluate(() => { const map = globalThis.__OCCUMED_MAP__; const canvas = map.getCanvas(); const rect = canvas.getBoundingClientRect(); const source = map.getStyle().sources?.['occumed-open'] || null; - const renderedWorldFeatureCount = map + const features = map .queryRenderedFeatures() - .filter((feature) => feature.source === 'occumed-open') - .length; + .filter((feature) => feature.source === 'occumed-open'); return { center: map.getCenter().toArray(), zoom: map.getZoom(), source, styleLoaded: map.isStyleLoaded(), - worldSourceLoadedDiagnostic: map.isSourceLoaded('occumed-open'), - allSourcesLoadedDiagnostic: map.areTilesLoaded(), - renderedWorldFeatureCount, + allTilesLoaded: map.areTilesLoaded(), + renderedWorldFeatureCount: features.length, + renderedSourceLayers: [...new Set(features.map((feature) => feature.sourceLayer))].sort(), canvas: { cssWidth: rect.width, cssHeight: rect.height, backingWidth: canvas.width, backingHeight: canvas.height, effectivePixelRatio: rect.width ? canvas.width / rect.width : null - }, - visibleLayers: map - .getStyle() - .layers.filter((layer) => map.getLayoutProperty(layer.id, 'visibility') !== 'none') - .map((layer) => layer.id) + } }; }); if (view.canvas.effectivePixelRatio < 1.9) { throw new Error(`${name} rendered below the required 2x effective pixel ratio.`); } - if (!view.styleLoaded || view.renderedWorldFeatureCount <= 0) { - throw new Error(`${name} did not render features from the selected worldwide PMTiles shard.`); + if (!view.styleLoaded || !view.allTilesLoaded || view.renderedWorldFeatureCount <= 0) { + throw new Error(`${name} did not render a completed worldwide vector view.`); } if ( - typeof view.source?.url !== 'string' || - !view.source.url.includes(`/world-tiles/${expectedRegion.asset}`) + view.source?.url || + JSON.stringify(view.source?.tiles || []) !== JSON.stringify([expectedTemplate]) ) { - throw new Error(`${name} rendered from the wrong worldwide PMTiles shard.`); + throw new Error(`${name} changed the permanent vector source.`); } const screenshot = await page.screenshot({ @@ -197,15 +135,17 @@ try { if (screenshot.length < 25_000) { throw new Error(`${name} produced an unexpectedly empty browser render.`); } - - return { ...view, screenshotBytes: screenshot.length, expectedRegion }; + return { ...view, screenshotBytes: screenshot.length }; } const views = { - fresnoRegional: await capture('fresno-regional-z8', [-119.55, 36.75], 8), + globe: await capture('world-globe-z2', [0, 20], 1.82), + northAmerica: await capture('north-america-z4', [-105, 40], 4), + usMexico: await capture('us-mexico-border-z7', [-106.5, 31.8], 7), + europe: await capture('europe-boundaries-z6', [12, 50], 6), + antimeridian: await capture('russia-antimeridian-z5', [178, 60], 5), fresnoCity: await capture('fresno-city-z11', [-119.7871, 36.7378], 11), - fresnoStreet: await capture('fresno-street-z14', [-119.7871, 36.7378], 14), - sierraTerrain: await capture('sierra-terrain-z10', [-118.85, 36.85], 10) + fresnoStreet: await capture('fresno-street-z14', [-119.7871, 36.7378], 14) }; const allowedFailure = (entry) => { @@ -218,40 +158,48 @@ try { ); const unexpectedNetworkFailures = networkFailures.filter((entry) => !allowedFailure(entry)); const unexpectedConsoleFailures = consoleFailures.filter((entry) => !allowedFailure(entry)); + const externalVectorRequests = vectorRequests.filter( + (url) => !url.startsWith(`${origin}/tiles/`) + ); const report = { generatedAt: new Date().toISOString(), origin, browser: 'chromium', - mode: 'worldwide-pinned-manifest-through-runtime-proxy', + mode: 'one-permanent-virtual-worldwide-source', viewport: { width: 1440, height: 1000, deviceScaleFactor: 2 }, - manifest: { - plannedRegionCount: manifest.plannedRegionCount, - availableRegionCount: manifest.availableRegionCount, - missingRegionCount: manifest.missingRegionCount, - regionCount: manifest.regions.length - }, - rangeChecks: [...rangeChecks.values()], + initialSource, views, + vectorRequests: [...new Set(vectorRequests)], + externalVectorRequests, + forbiddenRequests: [...new Set(forbiddenRequests)], pageErrors, - networkFailures, - consoleFailures, unexpectedNetworkFailures, unexpectedConsoleFailures, - passed: pageErrors.length === 0 && unexpectedNetworkFailures.length === 0 && unexpectedConsoleFailures.length === 0 + passed: + pageErrors.length === 0 && + unexpectedNetworkFailures.length === 0 && + unexpectedConsoleFailures.length === 0 && + externalVectorRequests.length === 0 && + forbiddenRequests.length === 0 }; - await fs.writeFile(path.join(outputDir, 'final-world-fresno-report.json'), `${JSON.stringify(report, null, 2)}\n`); + await fs.writeFile( + path.join(outputDir, 'continuous-world-report.json'), + `${JSON.stringify(report, null, 2)}\n` + ); if (!report.passed) { - throw new Error(`Worldwide Fresno browser validation failed: ${JSON.stringify({ + throw new Error(`Continuous-world browser validation failed: ${JSON.stringify({ pageErrors, unexpectedNetworkFailures, - unexpectedConsoleFailures + unexpectedConsoleFailures, + externalVectorRequests, + forbiddenRequests })}`); } - console.log(`Validated ${Object.keys(views).length} high-DPI Fresno/Sierra views against the completed worldwide PMTiles release.`); + console.log(`Validated ${Object.keys(views).length} high-DPI views through one permanent worldwide source.`); } finally { await browser.close(); } diff --git a/scripts/check-exact-exported-swatches.mjs b/scripts/check-exact-exported-swatches.mjs index 51be72fb..8a82b823 100644 --- a/scripts/check-exact-exported-swatches.mjs +++ b/scripts/check-exact-exported-swatches.mjs @@ -15,7 +15,8 @@ const expect = (condition, message) => { const color = (id, property) => layer(id)?.paint?.[property]; const EXACT = { - land: '#E0E0D1', + ocean: '#4F9CD6', + land: '#8FB86B', wood: '#83CC66CC', scrub: '#A3D48799', crop: '#D1DD8899', @@ -29,16 +30,17 @@ const EXACT = { waterShadow: '#7293EE' }; -expect(color('land', 'background-color') === EXACT.land, 'The exact exported land swatch changed.'); -expect(color('water', 'fill-color') === EXACT.water, 'The exact exported water blue changed.'); -expect(color('waterway', 'line-color') === EXACT.water, 'The exact exported waterway blue changed.'); -expect(color('water-shadow', 'fill-color') === EXACT.waterShadow, 'The exact exported water-shadow blue changed.'); -expect(color('waterway-shadow', 'line-color') === EXACT.waterShadow, 'The exact exported waterway-shadow blue changed.'); -expect(color('wetland', 'fill-color') === EXACT.wetland, 'The exact exported wetland blue changed.'); -expect(color('wetland-pattern', 'fill-color') === EXACT.wetland, 'The exact exported wetland-pattern blue changed.'); -expect(color('national-park', 'fill-color') === EXACT.park, 'The exact exported national-park green changed.'); -expect(color('national-park_tint-band', 'line-color') === EXACT.park, 'The exact exported park tint-band green changed.'); -expect(color('pitch-outline', 'line-color') === EXACT.pitch, 'The exact exported pitch green changed.'); +expect(color('land', 'background-color') === EXACT.ocean, 'The permanent ocean background changed.'); +expect(color('occumed-land-surface', 'fill-color') === EXACT.land, 'The saturated land surface changed.'); +expect(color('water', 'fill-color') === EXACT.water, 'The inland-water blue changed.'); +expect(color('waterway', 'line-color') === EXACT.water, 'The waterway blue changed.'); +expect(color('water-shadow', 'fill-color') === EXACT.waterShadow, 'The water-shadow blue changed.'); +expect(color('waterway-shadow', 'line-color') === EXACT.waterShadow, 'The waterway-shadow blue changed.'); +expect(color('wetland', 'fill-color') === EXACT.wetland, 'The wetland blue changed.'); +expect(color('wetland-pattern', 'fill-color') === EXACT.wetland, 'The wetland-pattern blue changed.'); +expect(color('national-park', 'fill-color') === EXACT.park, 'The national-park green changed.'); +expect(color('national-park_tint-band', 'line-color') === EXACT.park, 'The park tint-band green changed.'); +expect(color('pitch-outline', 'line-color') === EXACT.pitch, 'The pitch green changed.'); const landcoverColors = JSON.stringify(color('landcover', 'fill-color') || []); for (const [name, value] of Object.entries({ @@ -49,43 +51,29 @@ for (const [name, value] of Object.entries({ snow: EXACT.snow, fallback: EXACT.fallback })) { - expect(landcoverColors.includes(value), `The exact exported landcover ${name} swatch changed.`); + expect(landcoverColors.includes(value), `The landcover ${name} swatch changed.`); } expect(layer('landcover')?.minzoom === 0, 'Landcover no longer begins at globe zoom.'); -expect(JSON.stringify(color('landcover', 'fill-opacity')) === JSON.stringify([ - 'interpolate', ['linear'], ['zoom'], 0, 1, 11, 1, 12, 0 -]), 'Landcover is being multiplied by a different zoom opacity.'); -expect(color('landuse', 'fill-opacity') === 1, 'Detailed landuse colors are being weakened by an additional opacity.'); +expect(color('occumed-land-surface', 'fill-opacity') === 1, 'The worldwide land surface is translucent.'); +expect(color('landuse', 'fill-opacity') === 1, 'Detailed landuse colors are weakened.'); expect(color('water', 'fill-opacity') === 1, 'Water is not fully opaque.'); - -const relief = layer('occumed-shaded-relief'); -expect(relief?.paint?.['raster-saturation'] === -1, 'The independent relief raster is recoloring the exported palette.'); -expect(relief?.paint?.['raster-hue-rotate'] === 0, 'The independent relief raster is rotating exported hues.'); -expect(relief?.maxzoom === 7, 'Pixel-based relief does not disappear before detailed zooms.'); -const reliefOpacity = relief?.paint?.['raster-opacity'] || []; -const reliefOutputs = []; -for (let index = 4; index < reliefOpacity.length; index += 2) { - if (typeof reliefOpacity[index] === 'number') reliefOutputs.push(reliefOpacity[index]); -} -expect(reliefOutputs.includes(0.12), 'The neutral relief texture curve changed.'); -expect(reliefOutputs.every((value) => value >= 0 && value <= 0.12), 'The relief raster is opaque enough to distort the exported swatches.'); -expect(reliefOutputs.at(-1) === 0, 'The relief raster does not fully disappear by its final zoom stop.'); +expect(!runtime.layers.some((candidate) => candidate.type === 'raster'), 'A raster basemap was reintroduced.'); const hillshade = layer('occumed-hillshade'); -expect(hillshade?.paint?.['hillshade-shadow-color'] === '#0000004D', 'Hillshade shadow is tinting terrain instead of changing lightness only.'); -expect(hillshade?.paint?.['hillshade-highlight-color'] === '#FFFFFF4D', 'Hillshade highlight is tinting terrain instead of changing lightness only.'); -expect(hillshade?.paint?.['hillshade-accent-color'] === '#00000026', 'Hillshade accent is tinting terrain instead of changing lightness only.'); +expect(hillshade?.paint?.['hillshade-shadow-color'] === '#0000004D', 'Hillshade shadows are tinting terrain.'); +expect(hillshade?.paint?.['hillshade-highlight-color'] === '#FFFFFF4D', 'Hillshade highlights are tinting terrain.'); +expect(hillshade?.paint?.['hillshade-accent-color'] === '#00000026', 'Hillshade accents are tinting terrain.'); -expect(runtime.metadata?.['occumed:reference-color-system'] === 'exact-exported-swatches-v9', 'The exact exported swatch pass did not run.'); -expect(runtime.metadata?.['occumed:colored-relief-disabled'] === true, 'Colored relief protection is missing.'); +expect(runtime.metadata?.['occumed:reference-color-system'] === 'continuous-world-v10', 'The continuous-world color pass did not run.'); +expect(runtime.metadata?.['occumed:raster-relief-disabled'] === true, 'Raster relief protection is missing.'); expect(runtime.metadata?.['occumed:layer-specific-palette'] === true, 'Layer-specific palette protection is missing.'); expect(runtime.metadata?.['occumed:high-dpi-vector-clarity'] === true, 'High-DPI clarity protection is missing.'); if (failures.length) { - console.error('Exact exported swatch validation failed:'); + console.error('Continuous-world palette validation failed:'); for (const failure of failures) console.error(`- ${failure}`); process.exit(1); } -console.log('Exact exported swatches guarded for land, landcover, parks, wetlands, water, and water shadows; raster hue drift and detailed-zoom blur disabled.'); +console.log('Continuous-world palette guarded: saturated green land, clear blue water, and no raster fallback.'); diff --git a/scripts/check-globe-parity.mjs b/scripts/check-globe-parity.mjs index 43c78878..cf0cf56a 100644 --- a/scripts/check-globe-parity.mjs +++ b/scripts/check-globe-parity.mjs @@ -58,16 +58,11 @@ if (horizonOutputs.at(-1) !== 0) { fail('The horizon rim does not fade out before detailed zooms.'); } -const relief = layer('occumed-shaded-relief'); -if (!relief) fail('The low-zoom relief layer is missing.'); -if (relief?.paint?.['raster-saturation'] !== -1) fail('The independent relief raster is recoloring the exported palette.'); -if (relief?.paint?.['raster-hue-rotate'] !== 0) fail('The independent relief raster is rotating exported hues.'); -if ((relief?.paint?.['raster-contrast'] ?? 1) > 0.05) fail('Neutral relief contrast is high enough to distort exported swatches.'); -if ((relief?.maxzoom ?? 99) > 7) fail('Raster relief persists too far into regional or city zooms.'); -const reliefStops = (relief?.paint?.['raster-opacity'] || []).filter((value) => typeof value === 'number'); -if (reliefStops.some((value) => value > 7 ? false : value > 0.12 && value < 1)) { - fail('The independent relief raster is opaque enough to recolor the globe.'); +if (runtime.layers.some((candidate) => candidate.type === 'raster')) { + fail('A raster fallback basemap was reintroduced.'); } +if (layer('land')?.paint?.['background-color'] !== '#4F9CD6') fail('The permanent ocean blue changed.'); +if (layer('occumed-land-surface')?.paint?.['fill-color'] !== '#8FB86B') fail('The saturated land green changed.'); const landcover = layer('landcover'); if ((landcover?.minzoom ?? 99) !== 0) fail('Landcover is unavailable at globe zoom.'); @@ -93,10 +88,10 @@ const allColors = collectHex(runtime.layers.map((candidate) => candidate.paint | if (allColors.size < 25) fail(`The exported globe palette was flattened to only ${allColors.size} colors.`); if (!runtime.metadata?.['occumed:exported-cartography-restored']) fail('The exported-cartography restoration pass did not run.'); -if (runtime.metadata?.['occumed:reference-color-system'] !== 'exact-exported-swatches-v9') fail('The exact exported swatch pass did not run.'); +if (runtime.metadata?.['occumed:reference-color-system'] !== 'continuous-world-v10') fail('The continuous-world color pass did not run.'); if (runtime.metadata?.['occumed:palette-format'] !== 'fixed-hex-per-layer') fail('The per-layer fixed-hex palette marker is missing.'); if (runtime.metadata?.['occumed:layer-specific-palette'] !== true) fail('Layer-specific palette protection is missing.'); -if (runtime.metadata?.['occumed:colored-relief-disabled'] !== true) fail('Colored relief protection is missing.'); +if (runtime.metadata?.['occumed:raster-relief-disabled'] !== true) fail('Raster relief protection is missing.'); if (runtime.metadata?.['occumed:reference-atmosphere'] !== true) fail('The reference atmosphere pass did not run.'); if (runtime.metadata?.['occumed:atmosphere-surface-wash-disabled'] !== true) fail('Atmosphere surface-wash protection is missing.'); @@ -106,4 +101,4 @@ if (failures.length) { process.exit(1); } -console.log(`Globe parity validated: dark space, strong white-blue atmosphere, exact exported greens/blues, neutral terrain shading, and ${allColors.size} structure-specific colors.`); +console.log(`Globe parity validated: dark space, strong white-blue atmosphere, saturated green land, clear blue water, and ${allColors.size} structure-specific colors.`); diff --git a/scripts/check-live-world-tiles.mjs b/scripts/check-live-world-tiles.mjs new file mode 100644 index 00000000..f701908a --- /dev/null +++ b/scripts/check-live-world-tiles.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { inspectVectorTile } from '../src/server/mvt.js'; +import { WorldTileGateway } from '../src/server/world-tile-gateway.js'; + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (key === '--manifest') options.manifest = argv[++index]; + else if (key === '--release-base') options.releaseBase = argv[++index]; + } + for (const required of ['manifest', 'releaseBase']) { + if (!options[required]) throw new Error(`Missing --${required}.`); + } + return options; +} + +const options = parseArgs(process.argv.slice(2)); +const releaseBase = options.releaseBase.replace(/\/$/, ''); +const gateway = new WorldTileGateway({ + manifestUrl: options.manifest, + releaseAssetUrl: (asset) => `${releaseBase}/${encodeURIComponent(asset)}` +}); + +const checks = [ + { name: 'globe', z: 0, x: 0, y: 0, requireDetail: true }, + { name: 'North America and US–Mexico', z: 6, x: 11, y: 25, requireDetail: true }, + { name: 'multi-shard Europe', z: 6, x: 33, y: 21, requireDetail: true }, + { name: 'Russia east of the antimeridian', z: 6, x: 63, y: 15 }, + { name: 'Russia/Alaska west of the antimeridian', z: 6, x: 0, y: 15 } +]; + +const results = []; +for (const check of checks) { + const startedAt = performance.now(); + const first = await gateway.resolveTile(check.z, check.x, check.y); + const layers = inspectVectorTile(first); + const second = await gateway.resolveTile(check.z, check.x, check.y); + + if (!first.equals(second)) { + throw new Error(`${check.name}: cached virtual tile differs from its initial result.`); + } + if (!layers.land?.featureCount) { + throw new Error(`${check.name}: continuous worldwide land layer is missing.`); + } + if ( + check.requireDetail && + !Object.keys(layers).some((name) => name !== 'land') + ) { + throw new Error(`${check.name}: basemap detail layers are missing.`); + } + + results.push({ + name: check.name, + tile: `${check.z}/${check.x}/${check.y}`, + bytes: first.byteLength, + layers: Object.fromEntries( + Object.entries(layers).map(([name, layer]) => [name, layer.featureCount]) + ), + firstResolveMs: Math.round(performance.now() - startedAt) + }); +} + +console.log(JSON.stringify(results, null, 2)); +console.log('Live worldwide tile smoke checks passed across the globe, continental boundaries, and antimeridian.'); diff --git a/scripts/check-low-zoom-relief.mjs b/scripts/check-low-zoom-relief.mjs deleted file mode 100644 index 3023a8a0..00000000 --- a/scripts/check-low-zoom-relief.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const runtime = JSON.parse( - await fs.readFile(path.join(root, 'public/style/occumed-open.json'), 'utf8') -); - -const relief = runtime.layers.find((candidate) => candidate.id === 'occumed-shaded-relief'); -if (!relief) throw new Error('The open shaded-relief layer is missing.'); - -const expectedOpacity = JSON.stringify([ - 'interpolate', - ['linear'], - ['zoom'], - 0, 0.48, - 2.5, 0.44, - 4.5, 0.38, - 6.5, 0.32, - 8.5, 0 -]); -const actualOpacity = JSON.stringify(relief.paint?.['raster-opacity']); - -if (actualOpacity !== expectedOpacity) { - throw new Error('Regional physical-landscape opacity faded back to the washed-out beige configuration.'); -} -if (relief.paint?.['raster-saturation'] !== 0.12) { - throw new Error('Regional landscape saturation changed from the restrained approved value.'); -} -if (relief.paint?.['raster-contrast'] !== 0.09) { - throw new Error('Regional landscape contrast changed from the restrained approved value.'); -} -if (relief.maxzoom !== 8.5) { - throw new Error('The physical landscape must remain available through regional zooms and fade before city detail.'); -} -if (runtime.metadata?.['occumed:low-zoom-landscape-pass'] !== 8) { - throw new Error('The low-zoom landscape visibility pass did not run.'); -} -if (runtime.metadata?.['occumed:regional-relief-visible'] !== true) { - throw new Error('Regional landscape visibility protection is missing.'); -} - -console.log('Low-zoom landscape validated: restrained color remains visible from globe through regional zooms.'); diff --git a/scripts/check-photo-reference.mjs b/scripts/check-photo-reference.mjs index 3f86aa7b..2c0bcac4 100644 --- a/scripts/check-photo-reference.mjs +++ b/scripts/check-photo-reference.mjs @@ -47,7 +47,8 @@ assert(runtime.sky?.['horizon-fog-blend'] === 0, 'Horizon fog must remain disabl assert(!runtime.light, 'Directional light must not be reintroduced.'); assert(!runtime.fog, 'Mapbox fog must not be copied into the MapLibre runtime.'); -assert(layer('land')?.paint?.['background-color'] === '#E0E0D1', 'The exact exported land swatch changed.'); +assert(layer('land')?.paint?.['background-color'] === '#4F9CD6', 'The permanent ocean blue changed.'); +assert(layer('occumed-land-surface')?.paint?.['fill-color'] === '#8FB86B', 'The saturated land green changed.'); assert(layer('water')?.paint?.['fill-color'] === '#79BCEC', 'The exact exported water swatch changed.'); assert(layer('water-shadow')?.paint?.['fill-color'] === '#7293EE', 'The exact exported water-shadow swatch changed.'); assert(layer('wetland')?.paint?.['fill-color'] === '#A4CAD6', 'The exact exported wetland swatch changed.'); @@ -63,15 +64,7 @@ assert(JSON.stringify(landcover?.paint?.['fill-opacity'] || []).includes('1'), ' assert(layer('landuse')?.paint?.['fill-opacity'] === 1, 'Detailed landuse swatches are being weakened by extra opacity.'); assert(layer('water')?.paint?.['fill-opacity'] === 1, 'Water must remain fully opaque.'); -const relief = layer('occumed-shaded-relief'); -assert(relief?.paint?.['raster-saturation'] === -1, 'The independent relief raster is recoloring the exported palette.'); -assert(relief?.paint?.['raster-contrast'] === 0.04, 'The neutral relief contrast changed.'); -assert(relief?.paint?.['raster-hue-rotate'] === 0, 'The independent relief raster is rotating exported hues.'); -assert(relief?.maxzoom === 7, 'Pixel-based relief must disappear before regional and city detail.'); -const reliefOpacity = relief?.paint?.['raster-opacity'] || []; -assert(reliefOpacity.includes(0.12), 'The restrained neutral relief texture is missing.'); -assert(reliefOpacity.includes(7) && reliefOpacity.at(-1) === 0, 'Neutral relief does not fully disappear by zoom 7.'); -assert(!reliefOpacity.some((value) => typeof value === 'number' && value > 0.12 && value < 1), 'Relief opacity can distort the exported swatches.'); +assert(!runtime.layers.some((candidate) => candidate.type === 'raster'), 'A raster fallback basemap was reintroduced.'); const hillshade = layer('occumed-hillshade'); assert(hillshade?.minzoom === 1.5, 'Hillshade must begin early enough to shape the globe.'); @@ -114,11 +107,11 @@ assert(JSON.stringify(majorPlace?.layout?.['text-font'] || []).includes('Open Sa assert(layer('state-label')?.paint?.['text-opacity'] === 0.5, 'The exported muted state-label hierarchy was lost.'); assert(runtime.metadata?.['occumed:exported-cartography-restored'] === true, 'Exported cartography restoration marker is missing.'); -assert(runtime.metadata?.['occumed:reference-color-system'] === 'exact-exported-swatches-v9', 'Exact exported swatch marker is missing.'); -assert(runtime.metadata?.['occumed:live-visual-qa-pass'] === 9, 'Exact swatch build pass marker is missing.'); +assert(runtime.metadata?.['occumed:reference-color-system'] === 'continuous-world-v10', 'Continuous-world color marker is missing.'); +assert(runtime.metadata?.['occumed:live-visual-qa-pass'] === 10, 'Continuous-world visual pass marker is missing.'); assert(runtime.metadata?.['occumed:palette-format'] === 'fixed-hex-per-layer', 'Per-layer fixed-hex palette marker is missing.'); assert(runtime.metadata?.['occumed:layer-specific-palette'] === true, 'Layer-specific palette protection is missing.'); -assert(runtime.metadata?.['occumed:colored-relief-disabled'] === true, 'Colored relief protection is missing.'); +assert(runtime.metadata?.['occumed:raster-relief-disabled'] === true, 'Raster relief protection is missing.'); assert(runtime.metadata?.['occumed:high-dpi-vector-clarity'] === true, 'High-DPI clarity protection is missing.'); for (const [sourceId, source] of Object.entries(runtime.sources || {})) { @@ -130,4 +123,4 @@ assert(!/mapbox:\/\//i.test(runtime.sprite || ''), 'Runtime sprite must not use assert(!/api\.mapbox\.com/i.test(runtime.glyphs || ''), 'Runtime glyphs must not use Mapbox.'); assert(runtime.metadata?.['occumed:mapbox-runtime-dependency'] === false, 'No-Mapbox dependency marker is missing.'); -console.log(`Reference guard passed: exact exported greens/blues, high-DPI vector clarity, neutral terrain shading, and ${allColors.size} distinct colors.`); +console.log(`Reference guard passed: saturated green land, clear blue water, high-DPI vector clarity, and ${allColors.size} distinct colors.`); diff --git a/scripts/check-pmtiles-integration.mjs b/scripts/check-pmtiles-integration.mjs index 35d90018..3b6ad155 100644 --- a/scripts/check-pmtiles-integration.mjs +++ b/scripts/check-pmtiles-integration.mjs @@ -6,149 +6,106 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const [ pkg, helper, - router, - sourcePass, + styleBuilder, sourceProfile, - profilePreparer, - buildScript, - planner, - pinnedPlanner, manifestBuilder, + overviewBuilder, + gateway, server, workflow, - action, - capture, readme ] = await Promise.all([ fs.readFile(path.join(root, 'package.json'), 'utf8').then(JSON.parse), fs.readFile(path.join(root, 'src/occumed-map.js'), 'utf8'), - fs.readFile(path.join(root, 'src/world-pmtiles-router.js'), 'utf8'), - fs.readFile(path.join(root, 'scripts/use-custom-pmtiles-source.mjs'), 'utf8'), + fs.readFile(path.join(root, 'scripts/build-runtime-style.mjs'), 'utf8'), fs.readFile(path.join(root, 'planetiler/occumed-basemap.yml'), 'utf8'), - fs.readFile(path.join(root, 'scripts/prepare-planetiler-profile.mjs'), 'utf8'), - fs.readFile(path.join(root, 'planetiler/build-region.sh'), 'utf8'), - fs.readFile(path.join(root, 'scripts/plan-world-shards.mjs'), 'utf8'), - fs.readFile(path.join(root, 'scripts/plan-world-shards-final.mjs'), 'utf8'), fs.readFile(path.join(root, 'scripts/build-world-manifest.mjs'), 'utf8'), + fs.readFile(path.join(root, 'scripts/build-world-overview.mjs'), 'utf8'), + fs.readFile(path.join(root, 'src/server/world-tile-gateway.js'), 'utf8'), fs.readFile(path.join(root, 'server.mjs'), 'utf8'), - fs.readFile(path.join(root, '.github/workflows/repair-missing-world-pmtiles.yml'), 'utf8'), - fs.readFile(path.join(root, '.github/actions/build-pmtiles-shard-final/action.yml'), 'utf8'), - fs.readFile(path.join(root, 'scripts/capture-world-fresno-preview.mjs'), 'utf8'), + fs.readFile(path.join(root, '.github/workflows/build-virtual-world-tileset.yml'), 'utf8'), fs.readFile(path.join(root, 'README.md'), 'utf8') ]); const failures = []; const fail = (message) => failures.push(message); -if (pkg.dependencies?.pmtiles !== '4.4.1') fail('The PMTiles browser dependency is missing or unpinned.'); -if (pkg.scripts?.['tiles:plan-world'] !== 'node scripts/plan-world-shards.mjs --scope all') { - fail('The worldwide shard planning command is missing.'); +for (const [dependency, version] of Object.entries({ + pmtiles: '4.4.1', + '@mapbox/vector-tile': '2.0.4', + pbf: '4.0.1', + 'vt-pbf': '3.1.3' +})) { + if (pkg.dependencies?.[dependency] !== version) { + fail(`The pinned ${dependency} dependency is missing.`); + } } -if (!helper.includes("import { Protocol } from 'pmtiles';")) fail('The PMTiles protocol import is missing.'); -if (!helper.includes("maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile)")) fail('MapLibre does not register the PMTiles protocol.'); -if (!helper.includes("source.url.replaceAll('__OCCUMED_PUBLIC_ORIGIN__', styleOrigin)")) fail('PMTiles source URLs are not resolved against the deployed origin.'); -if (!helper.includes('installWorldPmtilesRouter')) fail('The shared map helper does not install worldwide PMTiles routing.'); -if (!helper.includes("style.metadata?.['occumed:world-manifest-url']")) fail('The shared map helper does not read the worldwide manifest URL.'); -if (!readme.includes('createOccumedMap')) fail('Reusable integration docs bypass the PMTiles-aware map helper.'); -if (!readme.includes('Directly fetching the style')) fail('Reusable integration docs do not reject unsupported raw-style construction.'); - -if (!router.includes('source.setUrl(nextUrl)')) fail('The worldwide router cannot swap regional PMTiles archives.'); -if (!router.includes('map.getZoom() >= switchZoom')) fail('The worldwide router does not preserve the low-zoom global overview.'); -if (!router.includes('occumedworldsourcechange')) fail('Worldwide source changes are not observable by consuming apps.'); -if (!router.includes('longitude >= west || longitude <= east')) fail('The worldwide router does not support antimeridian bounds.'); -if (!sourcePass.includes('OCCUMED_PMTILES_URL')) fail('The runtime style cannot be pointed at a custom PMTiles archive.'); -if (!sourcePass.includes('OCCUMED_WORLD_MANIFEST_URL')) fail('The runtime style cannot override the worldwide PMTiles manifest.'); -if (!sourcePass.includes("'__OCCUMED_PUBLIC_ORIGIN__/world-manifest.json'")) fail('The deployed worldwide manifest endpoint is not enabled by default.'); -if (!sourcePass.includes("'occumed:vector-source-mode': 'world-sharded-planetiler-pmtiles'")) fail('Worldwide PMTiles source metadata is missing.'); +if (!styleBuilder.includes("'__OCCUMED_PUBLIC_ORIGIN__/tiles/{z}/{x}/{y}.pbf'")) { + fail('The runtime style is not generated with the permanent virtual tile URL.'); +} +if (styleBuilder.toLowerCase().includes('openfreemap')) { + fail('The runtime style builder still configures OpenFreeMap.'); +} +if (helper.includes('Protocol') || helper.includes('setUrl(') || helper.includes('world-pmtiles-router')) { + fail('The browser helper still contains PMTiles shard routing.'); +} +if (!helper.includes('fadeDuration: 0')) fail('The browser can crossfade stale basemap tiles.'); +if (!helper.includes("source.tiles = source.tiles.map")) { + fail('Permanent vector tile templates are not resolved to the style origin.'); +} -if (!buildScript.includes('ghcr.io/onthegomap/planetiler:0.10.2')) fail('Planetiler is not pinned to the published 0.10.2 Docker image.'); -if (!buildScript.includes('prepare-planetiler-profile.mjs')) fail('PMTiles builds do not generate the corrected Planetiler profile.'); -if (!buildScript.includes('/profile/occumed-basemap.yml')) fail('PMTiles builds do not invoke the generated YAML profile directly.'); -if (buildScript.includes('generate-custom') || buildScript.includes('--schema=')) fail('PMTiles builds use unsupported custom-profile wrapper arguments.'); -if (!buildScript.includes('--osm-path="$CONTAINER_OSM_PATH"')) fail('PMTiles builds do not use Planetiler\'s supported local OSM path argument.'); -if (buildScript.includes('--osm-source-path=')) fail('PMTiles builds still use the unsupported osm-source-path argument.'); -if (!buildScript.includes('sha256sum "$OUTPUT_NAME"')) fail('PMTiles checksums are not portable across build paths.'); +if (!server.includes("const tileMatch = /^\\/tiles\\/(\\d+)\\/(\\d+)\\/(\\d+)\\.pbf$/")) { + fail('The server is missing the single virtual Z/X/Y endpoint.'); +} +if (server.includes('/world-tiles/')) fail('The server still publishes browser-visible regional archive paths.'); +if (!gateway.includes('regionsForTile(zoom, x, y)')) fail('The gateway does not route each requested tile by bounds.'); +if (!gateway.includes('Promise.all')) fail('The gateway cannot resolve intersecting archives together.'); +if (!gateway.includes('mergeVectorTiles')) fail('The gateway cannot merge MVT layers at shard boundaries.'); +if (!gateway.includes('MemoryTileCache')) fail('Resolved worldwide tiles are not cached.'); +if (!gateway.includes('overscaleVectorLayer')) fail('The land surface cannot remain continuous above its generalized zoom.'); + +if (!overviewBuilder.includes('mergeVectorTiles(payloads)')) { + fail('The low-zoom overview is not consolidated from the regional archives.'); +} +if (!manifestBuilder.includes('version: 2')) fail('The server-only routing manifest is not version 2.'); +if (!manifestBuilder.includes('overviewAsset')) fail('The manifest does not declare the consolidated overview archive.'); +if (!manifestBuilder.includes('surfaceAsset')) fail('The manifest does not declare the worldwide land surface.'); +if (manifestBuilder.includes('switchZoom')) fail('The obsolete browser switch zoom remains in the manifest.'); +if (manifestBuilder.includes('archiveProxyTemplate')) fail('The manifest still advertises regional archives to browsers.'); for (const marker of [ - 'version: "0.3.0"', - 'args.area.replace("/", "_")', - 'leisure: [garden, playground, golf_course, pitch]', - 'highway: [unclassified, residential, living_street]', - 'waterway: [river, canal, stream]', - 'name:latin' + 'Build consolidated zoom 0-5 overview', + 'Build worldwide land surface', + 'Publish virtual storage archives', + 'Publish server-only routing manifest', + 'occumed-world-overview.pmtiles', + 'occumed-world-surface.pmtiles' ]) { - if (!profilePreparer.includes(marker)) fail(`The generated Planetiler profile correction is missing: ${marker}`); + if (!workflow.includes(marker)) fail(`Virtual tileset workflow is missing: ${marker}`); } -if (!planner.includes('https://download.geofabrik.de/index-v1.json')) fail('The worldwide planner is not sourced from the Geofabrik index.'); -if (!planner.includes('hasDownloadableDescendant')) fail('The worldwide planner does not reject overlapping parent extracts recursively.'); -if (!planner.includes('DIRECT_PBF_LIMIT_BYTES')) fail('The worldwide planner does not identify oversized source extracts.'); -if (!planner.includes('splitRegion(region, sourceSizeBytes)')) fail('The worldwide planner cannot subdivide oversized sources.'); -if (!planner.includes("extract_bbox: `${roundedWest},${roundedSouth},${roundedEast},${roundedNorth}`")) fail('Bounded subdivision metadata is missing from the worldwide plan.'); -if (!planner.includes('coveredWidth > 300')) fail('The worldwide planner does not preserve global versus antimeridian-aware bounds.'); -if (!planner.includes('asset_name: `occumed-${slug}.pmtiles`')) fail('The worldwide planner does not produce deterministic archive names.'); - -if (!pinnedPlanner.includes('canonical-world-plan.json')) fail('The final completion planner is not pinned to the audited canonical inventory.'); -if (!pinnedPlanner.includes('forcedSplits')) fail('The final pinned planner does not preserve required oversized-parent replacements.'); -if (!pinnedPlanner.includes("antimeridian: true")) fail('The final pinned planner does not preserve the Far Eastern antimeridian split.'); -if (!pinnedPlanner.includes('extract_bbox: `${west},${south},${east},${north}`')) fail('The final pinned planner does not emit bounded child coordinates.'); -if (!pinnedPlanner.includes('plan.include.sort')) fail('The final pinned plan is not deterministically ordered.'); - -if (!manifestBuilder.includes("archiveTransport: 'same-origin-release-proxy'")) fail('The worldwide manifest does not use the release proxy transport.'); -if (!manifestBuilder.includes("url: `__OCCUMED_PUBLIC_ORIGIN__/world-tiles/${region.asset_name}`")) fail('The worldwide manifest does not route archives through the deployed origin.'); - -if (!server.includes("url.pathname === '/world-manifest.json'")) fail('The server does not proxy the worldwide manifest.'); -if (!server.includes('/^\\/world-tiles\\/(occumed-[a-z0-9-]+\\.pmtiles)$/')) fail('The server does not constrain worldwide PMTiles asset names.'); -if (!server.includes('Readable.fromWeb(upstream.body)')) fail('The server does not stream release archives.'); -if (!server.includes('if (request.headers.range) headers.Range = request.headers.range')) fail('The release proxy does not forward PMTiles byte ranges.'); - -if (!workflow.includes('Complete Only Pinned Missing Worldwide PMTiles')) fail('The pinned worldwide completion workflow is missing.'); -if (!workflow.includes('node scripts/plan-world-shards-final.mjs')) fail('The completion workflow does not generate the pinned canonical plan.'); -if ((workflow.match(/node scripts\/plan-world-shards-final\.mjs/g) || []).length !== 1) fail('The pinned canonical plan must be generated exactly once per completion run.'); -if (!workflow.includes('max-parallel: 24')) fail('The missing-only completion matrix does not request bounded concurrency.'); -if (!workflow.includes('matrix: ${{ fromJson(needs.plan.outputs.matrix) }}')) fail('The completion workflow does not schedule only the exact missing matrix.'); -if (!workflow.includes('extract-bbox: ${{ matrix.extract_bbox }}')) fail('The missing-only matrix does not pass bounded subdivision coordinates to the shard action.'); -if (!workflow.includes('uses: ./.github/actions/build-pmtiles-shard-final')) fail('The completion workflow does not use the hardened final shard action.'); -if (!workflow.includes('Publish manifest only when pinned plan has zero missing shards')) fail('The completion workflow does not publish the final manifest.'); -if (!workflow.includes('test "$missing" = "0"')) fail('The completion workflow does not reject incomplete worldwide coverage.'); -if (!workflow.includes('gh release upload "$WORLD_RELEASE_TAG" world-release/world-manifest.json')) fail('The completed manifest is not published to the release.'); - -if (!action.includes('Reuse existing nonzero release asset')) fail('Repair runs do not reuse nonzero published worldwide archives.'); -if (!action.includes('osmium extract --bbox="$EXTRACT_BBOX"')) fail('Oversized-region subdivisions are not extracted before Planetiler runs.'); -if (!action.includes("test \"$(head -c 7 \"$output\")\" = 'PMTiles'")) fail('Local shard validation does not assert the PMTiles magic header.'); -if (!action.includes('Archive exceeds GitHub Release safe file size')) fail('Shard publication does not reject unsafe oversized release assets.'); -if (!action.includes('gh release upload')) fail('Worldwide PMTiles shards are not published to GitHub Releases.'); -if (!action.includes('Release asset is still missing after upload')) fail('Shard publication does not verify a nonzero release asset after upload.'); - for (const requiredLayer of [ 'landcover', 'landuse', 'park', 'water', 'waterway', 'transportation', 'transportation_name', 'building', 'aeroway', 'aerodrome_label', 'boundary', 'place', 'poi', 'water_name', 'mountain_peak', 'housenumber' ]) { if (!sourceProfile.includes(`- id: ${requiredLayer}`)) { - fail(`Planetiler source profile is missing layer: ${requiredLayer}`); + fail(`Regional source profile is missing layer: ${requiredLayer}`); } } -for (const requiredAttribute of ['class', 'subclass', 'brunnel', 'ramp', 'ref', 'rank', 'admin_level', 'disputed']) { - if (!sourceProfile.includes(`key: ${requiredAttribute}`)) { - fail(`Planetiler source profile is missing normalized attribute: ${requiredAttribute}`); - } +if (!readme.includes('/tiles/{z}/{x}/{y}.pbf')) { + fail('The documented integration does not describe the one permanent tile source.'); +} +if (readme.includes('regional source routing') || readme.includes('global open-vector fallback')) { + fail('The documentation still describes two-map routing.'); } - -if (!capture.includes("fetch(`${origin}/world-manifest.json`")) fail('Final visual validation does not load the completed worldwide manifest through the runtime.'); -if (!capture.includes("headers: { Range: 'bytes=0-126' }")) fail('Final visual validation does not verify runtime PMTiles byte-range delivery.'); -if (!capture.includes("source.url.includes(`/world-tiles/${expectedAsset}`)")) fail('Final visual validation does not assert the selected worldwide shard.'); -if (!capture.includes('map.areTilesLoaded()')) fail('Final visual validation does not require all map tiles to load.'); -if (!capture.includes('effectivePixelRatio < 1.9')) fail('Final visual validation does not enforce 2x high-DPI rendering.'); -if (!capture.includes('unexpectedNetworkFailures')) fail('Final visual validation does not reject unexpected resource failures.'); -if (!capture.includes('missingRegionCount') || !capture.includes('!== 0')) fail('Final visual validation does not reject an incomplete manifest.'); if (failures.length) { - console.error('PMTiles integration validation failed:'); + console.error('Virtual PMTiles integration validation failed:'); for (const failure of failures) console.error(`- ${failure}`); process.exit(1); } -console.log('PMTiles integration validated: supported Planetiler YAML invocation, audited pinned planning, antimeridian-safe bounded child shards, missing-only 24-worker completion, hardened release publication, protocol registration, worldwide routing, same-origin range proxying, strict high-DPI Fresno evidence, and zero-missing manifest publication are present.'); +console.log('PMTiles storage integration validated behind one permanent worldwide vector endpoint.'); diff --git a/scripts/check-render-clarity.mjs b/scripts/check-render-clarity.mjs index 52c1b408..589b8b50 100644 --- a/scripts/check-render-clarity.mjs +++ b/scripts/check-render-clarity.mjs @@ -58,25 +58,9 @@ for (const id of requiredVectorLayers) { } } -const relief = layer('occumed-shaded-relief'); -if (!relief) { - fail('The restrained globe relief layer is missing.'); -} else { - if ((relief.maxzoom ?? 99) > 7) { - fail('Pixel-based relief persists into regional or city detail.'); - } - const opacity = JSON.stringify(relief.paint?.['raster-opacity'] || []); - if (!opacity.includes('7,0')) { - fail('Pixel-based relief does not fully disappear by zoom 7.'); - } - if (relief.paint?.['raster-resampling'] !== 'linear') { - fail('Globe relief is using pixelated nearest-neighbor resampling.'); - } -} - const rasterLayers = runtime.layers.filter((candidate) => candidate.type === 'raster'); -if (rasterLayers.some((candidate) => candidate.id !== 'occumed-shaded-relief')) { - fail('An additional raster layer can soften the vector basemap.'); +if (rasterLayers.length) { + fail('A raster basemap layer can soften or replace the continuous vector map.'); } if (runtime.metadata?.['occumed:high-dpi-vector-clarity'] !== true) { @@ -89,4 +73,4 @@ if (failures.length) { process.exit(1); } -console.log('Render clarity validated: 2x–3x canvas, antialiasing, @2x sprites, vector detail, and no city-level raster blur.'); +console.log('Render clarity validated: 2x–3x canvas, antialiasing, @2x sprites, and vector-only basemap detail.'); diff --git a/scripts/check-runtime.mjs b/scripts/check-runtime.mjs index b04fdd7f..11ac673b 100644 --- a/scripts/check-runtime.mjs +++ b/scripts/check-runtime.mjs @@ -31,12 +31,13 @@ if (runtime.metadata?.['occumed:glyph-rendering'] !== 'local-maplibre') { } if (!String(runtime.sprite).includes('/sprites/occumed')) fail('Local Occu-Med sprite endpoint is missing.'); -const allowedSources = new Set(['occumed-open', 'occumed-terrain', 'occumed-relief']); +const allowedSources = new Set(['occumed-open', 'occumed-terrain']); for (const sourceName of Object.keys(runtime.sources || {})) { if (!allowedSources.has(sourceName)) fail(`Unexpected shared source: ${sourceName}`); } const requiredSourceLayers = new Set([ + 'land', 'landcover', 'landuse', 'waterway', @@ -61,7 +62,8 @@ if (runtime.layers.length < minimumLayerCount) { fail(`Runtime style is too incomplete: ${runtime.layers.length} layers; expected at least ${minimumLayerCount}.`); } if (!runtime.layers.some((layer) => layer.id === 'occumed-hillshade')) fail('Open hillshade layer is missing.'); -if (!runtime.layers.some((layer) => layer.id === 'occumed-shaded-relief')) fail('Low-zoom relief layer is missing.'); +if (!runtime.layers.some((layer) => layer.id === 'occumed-land-surface')) fail('Worldwide land surface layer is missing.'); +if (runtime.layers.some((layer) => layer.type === 'raster')) fail('The runtime contains a second raster basemap.'); if (runtime.layers.filter((layer) => layer.type === 'symbol').length < 20) { fail('Too few label and symbol layers survived the conversion.'); } diff --git a/scripts/check-viewer-quality.mjs b/scripts/check-viewer-quality.mjs index d9668688..69bfc441 100644 --- a/scripts/check-viewer-quality.mjs +++ b/scripts/check-viewer-quality.mjs @@ -32,7 +32,8 @@ if (!helper.includes('zoom = 1.82')) fail('The standalone globe must start with if (!helper.includes('antialias: true')) fail('The standalone globe must render with antialiasing enabled.'); if (fonts.includes("'DIN Pro Medium', 'Open Sans Semibold'")) fail('DIN Pro Medium must not be replaced with an overly heavy open font.'); -if (layer('land')?.paint?.['background-color'] !== '#E0E0D1') fail('The exact exported land swatch changed.'); +if (layer('land')?.paint?.['background-color'] !== '#4F9CD6') fail('The permanent ocean blue changed.'); +if (layer('occumed-land-surface')?.paint?.['fill-color'] !== '#8FB86B') fail('The saturated land green changed.'); if (layer('water')?.paint?.['fill-color'] !== '#79BCEC') fail('The exact exported water blue changed.'); if (layer('water-shadow')?.paint?.['fill-color'] !== '#7293EE') fail('The exact exported water-shadow blue changed.'); if (layer('wetland')?.paint?.['fill-color'] !== '#A4CAD6') fail('The exact exported wetland blue changed.'); @@ -48,14 +49,7 @@ if (!JSON.stringify(landcover?.paint?.['fill-opacity'] || []).includes('1')) fai if (layer('landuse')?.paint?.['fill-opacity'] !== 1) fail('Detailed landuse swatches are being weakened by extra opacity.'); if (layer('water')?.paint?.['fill-opacity'] !== 1) fail('Water is translucent and will wash into the land background.'); -const relief = layer('occumed-shaded-relief'); -if (relief?.paint?.['raster-saturation'] !== -1) fail('The independent relief raster is recoloring the exported palette.'); -if (relief?.paint?.['raster-hue-rotate'] !== 0) fail('The independent relief raster is rotating exported hues.'); -if ((relief?.paint?.['raster-contrast'] ?? 1) > 0.05) fail('Neutral relief contrast is high enough to distort exported swatches.'); -if ((relief?.maxzoom ?? 99) > 8.5) fail('Raster relief persists too far into city zooms.'); -const reliefOpacity = relief?.paint?.['raster-opacity'] || []; -if (!reliefOpacity.includes(0.12)) fail('The restrained neutral relief texture is missing.'); -if (reliefOpacity.some((value) => typeof value === 'number' && value > 0.12 && value < 1)) fail('Relief opacity can distort exported swatches.'); +if (runtime.layers.some((candidate) => candidate.type === 'raster')) fail('A raster fallback basemap was reintroduced.'); const hillshade = layer('occumed-hillshade'); if (hillshade?.minzoom !== 1.5) fail('Hillshade begins too late to give the globe physical form.'); @@ -87,10 +81,10 @@ if (layer('admin-0-boundary')?.paint?.['line-opacity'] !== 0.82) fail('Country b if (layer('admin-1-boundary')?.paint?.['line-opacity'] !== 0.58) fail('State boundaries are too faint for regional views.'); if (!runtime.metadata?.['occumed:exported-cartography-restored']) fail('The exported vector cartography was not restored after endpoint translation.'); -if (runtime.metadata?.['occumed:reference-color-system'] !== 'exact-exported-swatches-v9') fail('The exact exported swatch pass did not run.'); +if (runtime.metadata?.['occumed:reference-color-system'] !== 'continuous-world-v10') fail('The continuous-world color pass did not run.'); if (runtime.metadata?.['occumed:palette-format'] !== 'fixed-hex-per-layer') fail('The per-layer fixed-hex palette marker is missing.'); if (runtime.metadata?.['occumed:layer-specific-palette'] !== true) fail('Layer-specific palette protection is missing.'); -if (runtime.metadata?.['occumed:colored-relief-disabled'] !== true) fail('Colored relief protection is missing.'); +if (runtime.metadata?.['occumed:raster-relief-disabled'] !== true) fail('Raster relief protection is missing.'); if (failures.length) { console.error('Standalone viewer quality validation failed:'); @@ -98,4 +92,4 @@ if (failures.length) { process.exit(1); } -console.log(`Viewer guard passed: clean chrome, exact exported greens/blues, neutral terrain shading, early hierarchy, and ${allColors.size} structure-specific colors.`); +console.log(`Viewer guard passed: clean chrome, saturated green land, clear blue water, early hierarchy, and ${allColors.size} structure-specific colors.`); diff --git a/scripts/check-world-tile-gateway.mjs b/scripts/check-world-tile-gateway.mjs new file mode 100644 index 00000000..de06e5cf --- /dev/null +++ b/scripts/check-world-tile-gateway.mjs @@ -0,0 +1,166 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import vtpbf from 'vt-pbf'; +import { + inspectVectorTile, + mergeVectorTiles, + overscaleVectorLayer +} from '../src/server/mvt.js'; +import { + normalizeTileCoordinates, + WorldTileRoutingIndex +} from '../src/server/world-tile-routing.js'; + +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const failures = []; +const expect = (condition, message) => { + if (!condition) failures.push(message); +}; + +function tile(layers) { + return Buffer.from(vtpbf.fromGeojsonVt(layers)); +} + +const westRoad = tile({ + transportation: { + features: [ + { + id: 42, + type: 2, + geometry: [[[0, 100], [2048, 100]]], + tags: { class: 'motorway', name: 'Continuous Road' } + } + ] + } +}); +const eastRoad = tile({ + transportation: { + features: [ + { + id: 42, + type: 2, + geometry: [[[0, 100], [2048, 100], [4096, 100]]], + tags: { class: 'motorway', name: 'Continuous Road' } + }, + { + id: 99, + type: 2, + geometry: [[[100, 200], [300, 300]]], + tags: { class: 'primary', name: 'Boundary Road' } + } + ] + } +}); + +const mergedRoads = inspectVectorTile(mergeVectorTiles([westRoad, eastRoad])); +expect(mergedRoads.transportation?.featureCount === 2, 'Duplicate road IDs were not merged.'); +expect( + JSON.stringify(mergedRoads.transportation?.ids) === JSON.stringify([42, 99]), + 'Merged feature IDs are not deterministic.' +); +expect( + mergedRoads.transportation?.pointCounts?.[0] === 3, + 'Complementary or contained road geometry was lost or duplicated at a shard boundary.' +); + +const surface = tile({ + land: { + features: [ + { + id: 1, + type: 3, + geometry: [[[0, 0], [4096, 0], [4096, 4096], [0, 4096], [0, 0]]], + tags: {} + } + ] + } +}); +const overscaled = inspectVectorTile(overscaleVectorLayer(surface, { + layerName: 'land', + sourceZoom: 10, + targetZoom: 16, + targetX: 32768, + targetY: 32768 +})); +expect(overscaled.land?.featureCount === 1, 'The worldwide land surface cannot overscale through max zoom.'); + +const regions = [ + { + id: 'west', + asset: 'occumed-west.pmtiles', + bounds: [-10, -10, 1, 10] + }, + { + id: 'east', + asset: 'occumed-east.pmtiles', + bounds: [0, -10, 10, 10] + }, + { + id: 'antimeridian', + asset: 'occumed-antimeridian.pmtiles', + bounds: [170, -20, -170, 20] + } +]; +const routing = new WorldTileRoutingIndex(regions, { routingZoom: 6 }); +const boundaryCandidates = routing.regionsForTile(6, 32, 31).map((region) => region.id); +expect(boundaryCandidates.includes('west'), 'The first shard intersecting a boundary tile is missing.'); +expect(boundaryCandidates.includes('east'), 'A shard intersecting a boundary tile is missing.'); +expect( + routing.regionsForTile(6, 63, 31).some((region) => region.id === 'antimeridian'), + 'The eastern antimeridian shard segment is missing.' +); +expect( + routing.regionsForTile(6, 0, 31).some((region) => region.id === 'antimeridian'), + 'The western antimeridian shard segment is missing.' +); + +expect(normalizeTileCoordinates(0, 0, 0), 'The globe tile coordinate was rejected.'); +expect(!normalizeTileCoordinates(6, 64, 0), 'An out-of-range tile coordinate was accepted.'); +expect(!normalizeTileCoordinates(17, 0, 0), 'A tile beyond the storage max zoom was accepted.'); + +const [runtime, helper, server, gateway, manifestBuilder] = await Promise.all([ + fs.readFile(path.join(root, 'public/style/occumed-open.json'), 'utf8').then(JSON.parse), + fs.readFile(path.join(root, 'src/occumed-map.js'), 'utf8'), + fs.readFile(path.join(root, 'server.mjs'), 'utf8'), + fs.readFile(path.join(root, 'src/server/world-tile-gateway.js'), 'utf8'), + fs.readFile(path.join(root, 'scripts/build-world-manifest.mjs'), 'utf8') +]); + +const vectorSources = Object.entries(runtime.sources || {}) + .filter(([, source]) => source.type === 'vector'); +expect(vectorSources.length === 1, `Expected one vector source; found ${vectorSources.length}.`); +expect(vectorSources[0]?.[0] === 'occumed-open', 'The permanent vector source ID changed.'); +expect( + JSON.stringify(vectorSources[0]?.[1]?.tiles) === + JSON.stringify(['__OCCUMED_PUBLIC_ORIGIN__/tiles/{z}/{x}/{y}.pbf']), + 'The vector source does not use the one permanent worldwide Z/X/Y endpoint.' +); +expect(!vectorSources[0]?.[1]?.url, 'The vector source can still be replaced through a TileJSON URL.'); +expect(!JSON.stringify(runtime).toLowerCase().includes('openfreemap'), 'OpenFreeMap is still active in the runtime style.'); +expect(!JSON.stringify(runtime).includes('pmtiles://'), 'A storage archive is still exposed in the runtime style.'); +expect(!helper.includes('setUrl('), 'The browser helper can still replace the vector source URL.'); +expect(!helper.includes("addProtocol('pmtiles'"), 'The browser still reads PMTiles storage shards directly.'); +expect(!helper.includes('WorldPmtilesRouter'), 'The browser still installs the removed regional router.'); +expect(helper.includes('fadeDuration: 0'), 'Tile fading can still expose stale cartography during zoom.'); +expect(server.includes('/tiles\\/(\\d+)\\/(\\d+)\\/(\\d+)\\.pbf'), 'The server does not expose the permanent worldwide tile route.'); +expect(server.includes("'world-virtual-manifest.json'"), 'The gateway does not use the isolated server-only manifest.'); +expect(!server.includes('/world-tiles/'), 'The server still exposes regional storage URLs to the browser.'); +expect(gateway.includes('mergeVectorTiles'), 'The server gateway cannot merge boundary tiles.'); +expect(gateway.includes('MemoryTileCache'), 'The server gateway does not cache resolved virtual tiles.'); +expect(manifestBuilder.includes('version: 2'), 'The server-only routing manifest is not version 2.'); +expect(!manifestBuilder.includes('switchZoom'), 'The obsolete browser switch zoom remains in the manifest.'); + +try { + await fs.access(path.join(root, 'src/world-pmtiles-router.js')); + expect(false, 'The browser-side regional PMTiles router still exists.'); +} catch { + // Expected: the obsolete router is gone. +} + +if (failures.length) { + console.error('Virtual worldwide tile gateway validation failed:'); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log('Virtual worldwide tileset validated: one permanent source, boundary merging, antimeridian routing, surface overscaling, caching, and no browser-visible shards.'); diff --git a/scripts/lock-exact-exported-swatches.mjs b/scripts/lock-exact-exported-swatches.mjs index b5e738de..1295554f 100644 --- a/scripts/lock-exact-exported-swatches.mjs +++ b/scripts/lock-exact-exported-swatches.mjs @@ -11,7 +11,8 @@ const layer = (id) => runtime.layers.find((candidate) => candidate.id === id); // Exact hex equivalents of the uploaded Studio swatches shown in style.json. // These are not broad category guesses. Every named structure keeps its own color. const EXACT = Object.freeze({ - land: '#E0E0D1', // hsl(60, 20%, 85%) + ocean: '#4F9CD6', + land: '#8FB86B', landcoverWood: '#83CC66CC', // hsla(103, 50%, 60%, 0.8) landcoverScrub: '#A3D48799', // hsla(98, 47%, 68%, 0.6) landcoverCrop: '#D1DD8899', // hsla(68, 55%, 70%, 0.6) @@ -37,9 +38,14 @@ function enterBy(candidate, zoom) { } // The base and natural-structure colors are fixed to the exact exported swatches. -const land = requireLayer('land'); -land.paint['background-color'] = EXACT.land; -land.paint['background-opacity'] = 1; +const ocean = requireLayer('land'); +ocean.paint['background-color'] = EXACT.ocean; +ocean.paint['background-opacity'] = 1; + +const land = requireLayer('occumed-land-surface'); +land.paint['fill-color'] = EXACT.land; +land.paint['fill-opacity'] = 1; +land.paint['fill-antialias'] = true; const landcover = requireLayer('landcover'); enterBy(landcover, 0); @@ -114,36 +120,6 @@ const waterwayShadow = requireLayer('waterway-shadow'); waterwayShadow.paint['line-color'] = EXACT.waterShadow; waterwayShadow.paint['line-opacity'] = 1; -// The independent Natural Earth raster was the source of the yellow/fluorescent -// and later washed-out color drift. Keep only faint grayscale physical texture at -// globe zoom, then remove it before regional/city detail so vector edges stay crisp. -const relief = requireLayer('occumed-shaded-relief'); -relief.maxzoom = 7; -relief.paint = { - 'raster-opacity': [ - 'interpolate', - ['linear'], - ['zoom'], - 0, 0.12, - 2.5, 0.09, - 4.5, 0.05, - 6, 0.02, - 7, 0 - ], - 'raster-saturation': -1, - 'raster-contrast': 0.04, - 'raster-hue-rotate': 0, - 'raster-brightness-min': 0.2, - 'raster-brightness-max': 0.95, - 'raster-resampling': 'linear', - 'raster-fade-duration': 0 -}; -relief.metadata = { - ...(relief.metadata || {}), - 'occumed:purpose': 'neutral globe texture that fades before regional vector detail', - 'occumed:exact-swatches-pass': 9 -}; - // Use neutral light and shadow only. Terrain may change lightness, but never hue. const hillshade = requireLayer('occumed-hillshade'); hillshade.minzoom = 1.5; @@ -169,15 +145,15 @@ hillshade.paint = { runtime.metadata = { ...(runtime.metadata || {}), - 'occumed:reference-color-system': 'exact-exported-swatches-v9', - 'occumed:reference-color-pass': 9, - 'occumed:live-visual-qa-pass': 9, + 'occumed:reference-color-system': 'continuous-world-v10', + 'occumed:reference-color-pass': 10, + 'occumed:live-visual-qa-pass': 10, 'occumed:palette-format': 'fixed-hex-per-layer', 'occumed:layer-specific-palette': true, - 'occumed:colored-relief-disabled': true, + 'occumed:raster-relief-disabled': true, 'occumed:high-dpi-vector-clarity': true, 'occumed:exact-swatches': EXACT }; await fs.writeFile(runtimePath, `${JSON.stringify(runtime, null, 2)}\n`); -console.log('Locked exact exported swatches, preserved vector detail, and faded pixel-based relief before regional zooms.'); +console.log('Locked the green-land/blue-water world palette and preserved sharp vector detail.'); diff --git a/scripts/pack-mbtiles.py b/scripts/pack-mbtiles.py new file mode 100644 index 00000000..4e74571f --- /dev/null +++ b/scripts/pack-mbtiles.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import argparse +import gzip +import json +import pathlib +import sqlite3 + + +def parse_args(): + parser = argparse.ArgumentParser(description="Pack a Z/X/Y PBF tree into MBTiles.") + parser.add_argument("--tiles", required=True) + parser.add_argument("--metadata", required=True) + parser.add_argument("--output", required=True) + return parser.parse_args() + + +args = parse_args() +tile_root = pathlib.Path(args.tiles) +metadata = json.loads(pathlib.Path(args.metadata).read_text(encoding="utf-8")) +output = pathlib.Path(args.output) +output.unlink(missing_ok=True) + +connection = sqlite3.connect(output) +connection.executescript( + """ + PRAGMA journal_mode=OFF; + PRAGMA synchronous=OFF; + CREATE TABLE metadata (name TEXT, value TEXT); + CREATE TABLE tiles ( + zoom_level INTEGER, + tile_column INTEGER, + tile_row INTEGER, + tile_data BLOB + ); + CREATE UNIQUE INDEX tile_index + ON tiles (zoom_level, tile_column, tile_row); + """ +) + +metadata_rows = [] +for key, value in metadata.items(): + if key == "vector_layers": + continue + metadata_rows.append((key, str(value))) +metadata_rows.append( + ( + "json", + json.dumps({"vector_layers": metadata.get("vector_layers", [])}, separators=(",", ":")), + ) +) +connection.executemany("INSERT INTO metadata (name, value) VALUES (?, ?)", metadata_rows) + +tiles = [] +for tile_path in sorted(tile_root.glob("*/*/*.pbf")): + zoom = int(tile_path.parts[-3]) + tile_x = int(tile_path.parts[-2]) + tile_y = int(tile_path.stem) + tms_y = (1 << zoom) - 1 - tile_y + payload = gzip.compress(tile_path.read_bytes(), compresslevel=6, mtime=0) + tiles.append((zoom, tile_x, tms_y, payload)) + +connection.executemany( + "INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)", + tiles, +) +connection.commit() +connection.close() +print(f"Packed {len(tiles)} vector tiles into {output}.") diff --git a/scripts/strengthen-low-zoom-relief.mjs b/scripts/strengthen-low-zoom-relief.mjs deleted file mode 100644 index f6b785a7..00000000 --- a/scripts/strengthen-low-zoom-relief.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const runtimePath = path.join(root, 'public/style/occumed-open.json'); -const runtime = JSON.parse(await fs.readFile(runtimePath, 'utf8')); - -const relief = runtime.layers.find((candidate) => candidate.id === 'occumed-shaded-relief'); -if (!relief) throw new Error('The open shaded-relief layer is missing.'); - -// The exported vector structures already retain their own exact colors. This pass -// changes only the independent physical-landscape raster so regional views do not -// collapse into a beige base before the user reaches city zoom. -relief.maxzoom = 8.5; -relief.paint ||= {}; -relief.paint['raster-opacity'] = [ - 'interpolate', - ['linear'], - ['zoom'], - 0, 0.48, - 2.5, 0.44, - 4.5, 0.38, - 6.5, 0.32, - 8.5, 0 -]; - -// Keep the restrained color treatment from the approved structure-color pass. -// The higher visibility comes from opacity persistence, not fluorescent saturation. -relief.paint['raster-saturation'] = 0.12; -relief.paint['raster-contrast'] = 0.09; -relief.paint['raster-hue-rotate'] = -6; -relief.paint['raster-brightness-min'] = 0.08; -relief.paint['raster-brightness-max'] = 0.96; -relief.paint['raster-resampling'] = 'linear'; -relief.paint['raster-fade-duration'] = 0; - -relief.metadata = { - ...(relief.metadata || {}), - 'occumed:purpose': 'visible colored physical landscape through globe and regional zooms', - 'occumed:low-zoom-landscape-pass': 8 -}; - -runtime.metadata = { - ...(runtime.metadata || {}), - 'occumed:low-zoom-landscape-pass': 8, - 'occumed:regional-relief-visible': true -}; - -await fs.writeFile(runtimePath, `${JSON.stringify(runtime, null, 2)}\n`); -console.log('Strengthened restrained colored relief through regional zooms without recoloring vector structures.'); diff --git a/scripts/use-custom-pmtiles-source.mjs b/scripts/use-custom-pmtiles-source.mjs deleted file mode 100644 index 36ed9702..00000000 --- a/scripts/use-custom-pmtiles-source.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const runtimePath = path.join(root, 'public/style/occumed-open.json'); -const runtime = JSON.parse(await fs.readFile(runtimePath, 'utf8')); - -const archiveUrl = process.env.OCCUMED_PMTILES_URL?.trim(); -const configuredWorldManifest = process.env.OCCUMED_WORLD_MANIFEST_URL?.trim(); -const worldManifestUrl = configuredWorldManifest === 'off' - ? null - : configuredWorldManifest || '__OCCUMED_PUBLIC_ORIGIN__/world-manifest.json'; -const source = runtime.sources?.['occumed-open']; - -if (!source || source.type !== 'vector') { - throw new Error('The runtime style is missing the shared Occu-Med vector source.'); -} - -if (worldManifestUrl) { - runtime.metadata = { - ...(runtime.metadata || {}), - 'occumed:vector-source-mode': 'world-sharded-planetiler-pmtiles', - 'occumed:custom-pmtiles-enabled': true, - 'occumed:world-pmtiles-enabled': true, - 'occumed:world-manifest-url': worldManifestUrl, - 'occumed:world-switch-zoom': 6 - }; -} else if (archiveUrl) { - const normalized = archiveUrl.startsWith('pmtiles://') ? archiveUrl : `pmtiles://${archiveUrl}`; - source.url = normalized.replaceAll('__OCCUMED_PUBLIC_ORIGIN__', '__OCCUMED_PUBLIC_ORIGIN__'); - delete source.tiles; - source.attribution = - '© OpenStreetMap contributors'; - - runtime.metadata = { - ...(runtime.metadata || {}), - 'occumed:vector-source-mode': 'custom-planetiler-pmtiles', - 'occumed:custom-pmtiles-enabled': true, - 'occumed:world-pmtiles-enabled': false - }; -} else { - runtime.metadata = { - ...(runtime.metadata || {}), - 'occumed:vector-source-mode': 'open-source-fallback', - 'occumed:custom-pmtiles-enabled': false, - 'occumed:world-pmtiles-enabled': false - }; -} - -await fs.writeFile(runtimePath, `${JSON.stringify(runtime, null, 2)}\n`); -console.log( - worldManifestUrl - ? `Configured worldwide PMTiles manifest: ${worldManifestUrl}` - : archiveUrl - ? `Configured custom PMTiles source: ${archiveUrl}` - : 'Custom PMTiles is disabled; retained the existing open vector fallback.' -); diff --git a/server.mjs b/server.mjs index 6bfbeb10..958897b9 100644 --- a/server.mjs +++ b/server.mjs @@ -2,20 +2,17 @@ import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import http from 'node:http'; import path from 'node:path'; -import { Readable } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { WorldTileGateway } from './src/server/world-tile-gateway.js'; +import { normalizeTileCoordinates } from './src/server/world-tile-routing.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'dist'); const port = Number(process.env.PORT || 4173); const host = process.env.HOST?.trim() || '0.0.0.0'; const worldReleaseRepository = process.env.OCCUMED_WORLD_RELEASE_REPOSITORY?.trim() || 'Occumed79/Map'; const worldReleaseTag = process.env.OCCUMED_WORLD_RELEASE_TAG?.trim() || 'occumed-world-v1'; -const worldManifestAsset = 'world-manifest.json'; -const configuredUpstreamTimeout = Number(process.env.OCCUMED_UPSTREAM_TIMEOUT_MS || 20_000); -const upstreamTimeoutMs = Number.isFinite(configuredUpstreamTimeout) - ? Math.max(configuredUpstreamTimeout, 1_000) - : 20_000; +const worldManifestAsset = 'world-virtual-manifest.json'; const contentTypes = { '.css': 'text/css; charset=utf-8', @@ -93,166 +90,35 @@ function releaseAssetUrl(assetName) { return `https://github.com/${worldReleaseRepository}/releases/download/${encodeURIComponent(worldReleaseTag)}/${encodeURIComponent(assetName)}`; } -function copyUpstreamHeaders(upstream, response, cacheControl) { - const headerNames = [ - 'accept-ranges', - 'content-length', - 'content-range', - 'etag', - 'last-modified' - ]; - for (const name of headerNames) { - const value = upstream.headers.get(name); - if (value) response.setHeader(name, value); - } - response.setHeader('Cache-Control', cacheControl); - response.setHeader('CDN-Cache-Control', cacheControl); - response.setHeader('Surrogate-Control', cacheControl); - for (const [name, value] of Object.entries(corsHeaders)) response.setHeader(name, value); -} - -function createUpstreamContext(request, response) { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(new Error(`Upstream request exceeded ${upstreamTimeoutMs}ms.`)); - }, upstreamTimeoutMs); - timeout.unref?.(); - - const abortForRequest = () => { - controller.abort(new Error('Client disconnected before the upstream request completed.')); - }; - const abortForResponse = () => { - if (!response.writableFinished) abortForRequest(); - }; - - request.once('aborted', abortForRequest); - response.once('close', abortForResponse); - - return { - signal: controller.signal, - cleanup() { - clearTimeout(timeout); - request.off('aborted', abortForRequest); - response.off('close', abortForResponse); - } - }; -} +const worldTileGateway = new WorldTileGateway({ + manifestUrl: + process.env.OCCUMED_WORLD_MANIFEST_URL?.trim() || + releaseAssetUrl(worldManifestAsset), + releaseAssetUrl +}); -function isUpstreamAbort(error, signal) { - return Boolean( - signal.aborted || - error?.name === 'AbortError' || - error?.name === 'TimeoutError' || - error?.code === 'ABORT_ERR' +async function serveVirtualTile(request, response, coordinates) { + const tile = await worldTileGateway.resolveTile( + coordinates.z, + coordinates.x, + coordinates.y ); -} - -function handleUpstreamAbort(request, response, label) { - if (request.aborted || response.destroyed) return; - if (!response.headersSent) { - send( - response, - 504, - `${label} upstream timed out`, - 'text/plain; charset=utf-8', - 'no-store', - request.method - ); - } else { - response.destroy(); - } -} - -async function proxyReleaseAsset(request, response, assetName, contentType, cacheControl) { - const headers = { - 'User-Agent': 'Occu-Med-Map/world-pmtiles-proxy', - Accept: '*/*' - }; - if (request.headers.range) headers.Range = request.headers.range; - - const upstreamContext = createUpstreamContext(request, response); - try { - const upstream = await fetch(releaseAssetUrl(assetName), { - method: request.method, - headers, - redirect: 'follow', - signal: upstreamContext.signal - }); - - if (!upstream.ok && upstream.status !== 206 && upstream.status !== 416) { - send( - response, - upstream.status === 404 ? 404 : 502, - upstream.status === 404 ? 'Worldwide map asset not built yet' : 'Worldwide map asset upstream failed', - 'text/plain; charset=utf-8', - 'no-store', - request.method - ); - return; - } - - response.statusCode = upstream.status; - copyUpstreamHeaders(upstream, response, cacheControl); - response.setHeader('Content-Type', upstream.headers.get('content-type') || contentType); - if (request.method === 'HEAD' || !upstream.body) { - response.end(); - return; - } - await pipeline(Readable.fromWeb(upstream.body), response, { - signal: upstreamContext.signal - }); - } catch (error) { - if (isUpstreamAbort(error, upstreamContext.signal)) { - handleUpstreamAbort(request, response, 'Worldwide map asset'); - return; - } - throw error; - } finally { - upstreamContext.cleanup(); - } -} + const compressed = gzipSync(tile, { level: 6 }); + const cacheControl = 'public, max-age=86400, stale-while-revalidate=604800'; -async function serveWorldManifest(request, response) { - const upstreamContext = createUpstreamContext(request, response); - try { - const upstream = await fetch(releaseAssetUrl(worldManifestAsset), { - headers: { - 'User-Agent': 'Occu-Med-Map/world-manifest-proxy', - Accept: 'application/json' - }, - redirect: 'follow', - signal: upstreamContext.signal - }); - if (!upstream.ok) { - send( - response, - upstream.status === 404 ? 404 : 502, - upstream.status === 404 ? 'Worldwide map manifest not built yet' : 'Worldwide map manifest upstream failed', - 'text/plain; charset=utf-8', - 'no-store', - request.method - ); - return; - } - const manifest = (await upstream.text()) - .replaceAll('__OCCUMED_PUBLIC_ORIGIN__', requestOrigin(request)); - send( - response, - 200, - manifest, - contentTypes['.json'], - 'public, max-age=300, must-revalidate', - request.method - ); - } catch (error) { - if (isUpstreamAbort(error, upstreamContext.signal)) { - handleUpstreamAbort(request, response, 'Worldwide map manifest'); - return; - } - throw error; - } finally { - upstreamContext.cleanup(); - } + response.writeHead(200, { + ...corsHeaders, + 'Cache-Control': cacheControl, + 'CDN-Cache-Control': cacheControl, + 'Surrogate-Control': cacheControl, + 'Content-Encoding': 'gzip', + 'Content-Length': compressed.byteLength, + 'Content-Type': contentTypes['.pbf'], + 'Vary': 'Accept-Encoding', + 'X-Occumed-Tileset': 'virtual-worldwide-v1' + }); + if (request.method === 'HEAD') response.end(); + else response.end(compressed); } function parseByteRange(header, size) { @@ -409,20 +275,18 @@ async function handleRequest(request, response) { return; } - if (url.pathname === '/world-manifest.json') { - await serveWorldManifest(request, response); - return; - } - - const worldAssetMatch = /^\/world-tiles\/(occumed-[a-z0-9-]+\.pmtiles)$/.exec(url.pathname); - if (worldAssetMatch) { - await proxyReleaseAsset( - request, - response, - worldAssetMatch[1], - contentTypes['.pmtiles'], - 'public, max-age=86400, immutable' + const tileMatch = /^\/tiles\/(\d+)\/(\d+)\/(\d+)\.pbf$/.exec(url.pathname); + if (tileMatch) { + const coordinates = normalizeTileCoordinates( + tileMatch[1], + tileMatch[2], + tileMatch[3] ); + if (!coordinates) { + send(response, 404, 'Tile not found', 'text/plain; charset=utf-8', 'no-store', method); + return; + } + await serveVirtualTile(request, response, coordinates); return; } @@ -456,8 +320,8 @@ const server = http.createServer((request, response) => { }); }); -server.requestTimeout = 30_000; -server.headersTimeout = 35_000; +server.requestTimeout = 45_000; +server.headersTimeout = 50_000; server.keepAliveTimeout = 5_000; server.on('error', (error) => { diff --git a/src/occumed-map.js b/src/occumed-map.js index 03e22c3d..59fdb159 100644 --- a/src/occumed-map.js +++ b/src/occumed-map.js @@ -1,20 +1,10 @@ import * as maplibregl from 'maplibre-gl'; -import { Protocol } from 'pmtiles'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { installWorldPmtilesRouter } from './world-pmtiles-router.js'; export const DEFAULT_STYLE_URL = '/style/occumed-open.json'; const MIN_RENDER_PIXEL_RATIO = 2; const MAX_RENDER_PIXEL_RATIO = 3; -const pmtilesProtocol = new Protocol(); -let protocolRegistered = false; - -function ensurePmtilesProtocol() { - if (protocolRegistered) return; - maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile); - protocolRegistered = true; -} export function resolveOccumedPixelRatio() { const deviceRatio = Number(globalThis.devicePixelRatio); @@ -34,11 +24,13 @@ function resolvePublicOrigin(style, styleUrl) { if (typeof source?.url === 'string') { source.url = source.url.replaceAll('__OCCUMED_PUBLIC_ORIGIN__', styleOrigin); } - } - - if (typeof resolved.metadata?.['occumed:world-manifest-url'] === 'string') { - resolved.metadata['occumed:world-manifest-url'] = resolved.metadata['occumed:world-manifest-url'] - .replaceAll('__OCCUMED_PUBLIC_ORIGIN__', styleOrigin); + if (Array.isArray(source?.tiles)) { + source.tiles = source.tiles.map((tileUrl) => + typeof tileUrl === 'string' + ? tileUrl.replaceAll('__OCCUMED_PUBLIC_ORIGIN__', styleOrigin) + : tileUrl + ); + } } return resolved; @@ -77,7 +69,7 @@ export async function createOccumedMap({ center = [-98.5, 25], zoom = 1.82, minZoom = 0, - maxZoom = 19, + maxZoom = 16, controls = true, scaleControl = false, mapOptions = {} @@ -86,10 +78,7 @@ export async function createOccumedMap({ throw new TypeError('createOccumedMap requires a map container.'); } - ensurePmtilesProtocol(); const style = await loadOccumedStyle(styleUrl); - const fallbackUrl = style.sources?.['occumed-open']?.url; - const manifestUrl = style.metadata?.['occumed:world-manifest-url']; const map = new maplibregl.Map({ container, style, @@ -102,17 +91,13 @@ export async function createOccumedMap({ hash: false, pixelRatio: resolveOccumedPixelRatio(), antialias: true, + fadeDuration: 0, renderWorldCopies: false, attributionControl: false, cooperativeGestures: false, ...mapOptions }); - map.occumedWorldRouter = installWorldPmtilesRouter(map, { - manifestUrl, - fallbackUrl - }); - if (controls) { map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right'); } diff --git a/src/server/mvt.js b/src/server/mvt.js new file mode 100644 index 00000000..39055301 --- /dev/null +++ b/src/server/mvt.js @@ -0,0 +1,285 @@ +import { createHash } from 'node:crypto'; +import { VectorTile } from '@mapbox/vector-tile'; +import Pbf from 'pbf'; +import vtpbf from 'vt-pbf'; + +export const EMPTY_MVT = Buffer.from(vtpbf.fromVectorTileJs({ layers: {} })); + +function decodeTile(data) { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + return new VectorTile(new Pbf(bytes)); +} + +function stableProperties(properties) { + return Object.keys(properties || {}) + .sort() + .map((key) => [key, properties[key]]); +} + +function geometrySignature(feature) { + const hash = createHash('sha1'); + hash.update(`${feature.type}|${JSON.stringify(stableProperties(feature.properties))}|`); + for (const line of feature.loadGeometry()) { + hash.update('['); + for (const point of line) hash.update(`${point.x},${point.y};`); + hash.update(']'); + } + return hash.digest('base64url'); +} + +function featureKey(feature) { + if (feature.id !== undefined && feature.id !== null) { + return `${feature.type}:id:${feature.id}`; + } + return `${feature.type}:shape:${geometrySignature(feature)}`; +} + +function isSaneGeometry(feature, extent, coordinateScale = 64) { + const limit = extent * coordinateScale; + let pointCount = 0; + for (const line of feature.loadGeometry()) { + for (const point of line) { + if ( + !Number.isFinite(point.x) || + !Number.isFinite(point.y) || + Math.abs(point.x) > limit || + Math.abs(point.y) > limit + ) { + return false; + } + pointCount += 1; + } + } + return pointCount > 0; +} + +class MergedLayer { + constructor(name, version, extent, features) { + this.name = name; + this.version = version; + this.extent = extent; + this.features = features; + this.length = features.length; + } + + feature(index) { + return this.features[index]; + } +} + +function pointsEqual(left, right) { + return left.x === right.x && left.y === right.y; +} + +function lineContains(line, candidate) { + if (candidate.length > line.length) return false; + for (let start = 0; start <= line.length - candidate.length; start += 1) { + let forward = true; + let reverse = true; + for (let index = 0; index < candidate.length; index += 1) { + forward &&= pointsEqual(line[start + index], candidate[index]); + reverse &&= pointsEqual( + line[start + index], + candidate[candidate.length - index - 1] + ); + if (!forward && !reverse) break; + } + if (forward || reverse) return true; + } + return false; +} + +function mergeGeometryParts(existing, incoming) { + const parts = existing.map((line) => line.map((point) => ({ ...point }))); + for (const candidate of incoming) { + if (parts.some((line) => lineContains(line, candidate))) continue; + for (let index = parts.length - 1; index >= 0; index -= 1) { + if (lineContains(candidate, parts[index])) parts.splice(index, 1); + } + parts.push(candidate.map((point) => ({ ...point }))); + } + return parts; +} + +class CombinedFeature { + constructor(feature) { + this.id = feature.id; + this.type = feature.type; + this.properties = feature.properties; + this.extent = feature.extent; + this.geometry = feature.loadGeometry(); + } + + merge(feature) { + // Point labels get one deterministic anchor. Lines and polygon rings retain + // complementary pieces clipped into neighboring storage extracts. + if (this.type !== 1) { + this.geometry = mergeGeometryParts(this.geometry, feature.loadGeometry()); + } + } + + loadGeometry() { + return this.geometry; + } +} + +/** + * Merges independently generated MVT payloads into one valid tile. + * + * Planetiler preserves OSM feature IDs in the Occu-Med schema. When adjacent + * extracts contain the same feature, complementary line and ring parts are + * retained under one feature ID while contained duplicates collapse. Point + * labels retain one anchor. No unrelated polygon rings are stitched together. + */ +export function mergeVectorTiles(payloads, { + includeLayers, + coordinateScale = 64 +} = {}) { + const allowedLayers = includeLayers ? new Set(includeLayers) : null; + const layers = new Map(); + + for (const payload of payloads.filter(Boolean)) { + const tile = decodeTile(payload); + for (const [name, sourceLayer] of Object.entries(tile.layers)) { + if (allowedLayers && !allowedLayers.has(name)) continue; + + let target = layers.get(name); + if (!target) { + target = { + name, + version: sourceLayer.version, + extent: sourceLayer.extent, + features: new Map() + }; + layers.set(name, target); + } else if (target.extent !== sourceLayer.extent) { + throw new Error( + `Cannot merge MVT layer ${name} with extents ${target.extent} and ${sourceLayer.extent}.` + ); + } + + for (let index = 0; index < sourceLayer.length; index += 1) { + const feature = sourceLayer.feature(index); + if (!isSaneGeometry(feature, target.extent, coordinateScale)) continue; + + const key = featureKey(feature); + const existing = target.features.get(key); + if (existing) existing.merge(feature); + else target.features.set(key, new CombinedFeature(feature)); + } + } + } + + if (!layers.size) return EMPTY_MVT; + + const encodedLayers = {}; + for (const [name, layer] of layers) { + const features = [...layer.features.values()]; + if (!features.length) continue; + encodedLayers[name] = new MergedLayer( + name, + layer.version, + layer.extent, + features + ); + } + + if (!Object.keys(encodedLayers).length) return EMPTY_MVT; + return Buffer.from(vtpbf.fromVectorTileJs({ layers: encodedLayers })); +} + +class TransformedFeature { + constructor(feature, scale, offsetX, offsetY, extent) { + this.id = feature.id; + this.type = feature.type; + this.properties = feature.properties; + this.extent = extent; + this.source = feature; + this.scale = scale; + this.offsetX = offsetX; + this.offsetY = offsetY; + } + + loadGeometry() { + return this.source.loadGeometry().map((line) => + line.map((point) => ({ + x: point.x * this.scale - this.offsetX, + y: point.y * this.scale - this.offsetY + })) + ); + } +} + +/** + * Overscales one layer from an ancestor tile without changing its source-layer + * name. MapLibre still receives one ordinary MVT payload at the requested Z/X/Y. + */ +export function overscaleVectorLayer(payload, { + layerName, + sourceZoom, + targetZoom, + targetX, + targetY +}) { + if (targetZoom < sourceZoom) { + throw new Error('The target zoom must be greater than or equal to the source zoom.'); + } + + const tile = decodeTile(payload); + const sourceLayer = tile.layers[layerName]; + if (!sourceLayer) return EMPTY_MVT; + if (targetZoom === sourceZoom) { + return mergeVectorTiles([payload], { includeLayers: [layerName] }); + } + + const scale = 2 ** (targetZoom - sourceZoom); + const childX = ((targetX % scale) + scale) % scale; + const childY = ((targetY % scale) + scale) % scale; + const offsetX = childX * sourceLayer.extent; + const offsetY = childY * sourceLayer.extent; + const features = []; + + for (let index = 0; index < sourceLayer.length; index += 1) { + features.push( + new TransformedFeature( + sourceLayer.feature(index), + scale, + offsetX, + offsetY, + sourceLayer.extent + ) + ); + } + + if (!features.length) return EMPTY_MVT; + return Buffer.from(vtpbf.fromVectorTileJs({ + layers: { + [layerName]: new MergedLayer( + layerName, + sourceLayer.version, + sourceLayer.extent, + features + ) + } + })); +} + +export function inspectVectorTile(payload) { + const tile = decodeTile(payload); + return Object.fromEntries( + Object.entries(tile.layers).map(([name, layer]) => [ + name, + { + extent: layer.extent, + featureCount: layer.length, + ids: Array.from({ length: layer.length }, (_, index) => layer.feature(index).id), + pointCounts: Array.from({ length: layer.length }, (_, index) => + layer.feature(index).loadGeometry().reduce( + (total, line) => total + line.length, + 0 + ) + ) + } + ]) + ); +} diff --git a/src/server/pmtiles-source.js b/src/server/pmtiles-source.js new file mode 100644 index 00000000..6302ddd1 --- /dev/null +++ b/src/server/pmtiles-source.js @@ -0,0 +1,46 @@ +import { FetchSource } from 'pmtiles'; + +const DEFAULT_ATTEMPTS = 3; +const DEFAULT_TIMEOUT_MS = 12_000; + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** + * Adds bounded retries and per-range timeouts around PMTiles' HTTP source. + * GitHub release storage redirects every byte-range request, so a transient + * redirect or object-storage failure must not turn into a blank map tile. + */ +export class RetryingFetchSource { + constructor(url, { + attempts = DEFAULT_ATTEMPTS, + timeoutMs = DEFAULT_TIMEOUT_MS + } = {}) { + this.source = new FetchSource(url); + this.attempts = attempts; + this.timeoutMs = timeoutMs; + } + + getKey() { + return this.source.getKey(); + } + + async getBytes(offset, length, passedSignal, etag) { + let lastError; + for (let attempt = 1; attempt <= this.attempts; attempt += 1) { + const timeoutSignal = AbortSignal.timeout(this.timeoutMs); + const signal = passedSignal + ? AbortSignal.any([passedSignal, timeoutSignal]) + : timeoutSignal; + try { + return await this.source.getBytes(offset, length, signal, etag); + } catch (error) { + lastError = error; + if (passedSignal?.aborted || attempt === this.attempts) throw error; + await delay(150 * attempt); + } + } + throw lastError; + } +} diff --git a/src/server/world-tile-gateway.js b/src/server/world-tile-gateway.js new file mode 100644 index 00000000..5cd9245a --- /dev/null +++ b/src/server/world-tile-gateway.js @@ -0,0 +1,259 @@ +import { PMTiles, SharedPromiseCache } from 'pmtiles'; +import { + EMPTY_MVT, + mergeVectorTiles, + overscaleVectorLayer +} from './mvt.js'; +import { RetryingFetchSource } from './pmtiles-source.js'; +import { WorldTileRoutingIndex } from './world-tile-routing.js'; + +const DEFAULT_OVERVIEW_ASSET = 'occumed-world-overview.pmtiles'; +const DEFAULT_SURFACE_ASSET = 'occumed-world-surface.pmtiles'; +const DEFAULT_OVERVIEW_MAX_ZOOM = 5; +const DEFAULT_SURFACE_MAX_ZOOM = 10; +const DEFAULT_ROUTING_ZOOM = 6; +const DEFAULT_MAX_ZOOM = 16; +const DEFAULT_CACHE_BYTES = 128 * 1024 * 1024; + +class MemoryTileCache { + constructor(maxBytes = DEFAULT_CACHE_BYTES) { + this.maxBytes = maxBytes; + this.size = 0; + this.entries = new Map(); + } + + get(key) { + const entry = this.entries.get(key); + if (!entry) return null; + this.entries.delete(key); + this.entries.set(key, entry); + return entry; + } + + set(key, value) { + const data = Buffer.from(value); + const existing = this.entries.get(key); + if (existing) { + this.size -= existing.byteLength; + this.entries.delete(key); + } + + this.entries.set(key, data); + this.size += data.byteLength; + while (this.size > this.maxBytes && this.entries.size > 1) { + const oldestKey = this.entries.keys().next().value; + const oldest = this.entries.get(oldestKey); + this.entries.delete(oldestKey); + this.size -= oldest.byteLength; + } + return data; + } +} + +function requireAssetName(value, label) { + const asset = String(value || ''); + if (!/^occumed-[a-z0-9-]+\.pmtiles$/.test(asset)) { + throw new Error(`The worldwide manifest has an invalid ${label} asset.`); + } + return asset; +} + +function parseManifest(manifest) { + if (Number(manifest?.version) !== 2) { + throw new Error('The worldwide gateway requires the server-only version 2 manifest.'); + } + if (!manifest || !Array.isArray(manifest.regions) || !manifest.regions.length) { + throw new Error('The worldwide manifest does not contain regional storage shards.'); + } + + const virtual = manifest.virtualTiles || {}; + if (virtual.endpoint !== '/tiles/{z}/{x}/{y}.pbf') { + throw new Error('The worldwide manifest does not declare the permanent vector endpoint.'); + } + const overviewAsset = requireAssetName( + virtual.overviewAsset || DEFAULT_OVERVIEW_ASSET, + 'overview' + ); + const surfaceAsset = requireAssetName( + virtual.surfaceAsset || DEFAULT_SURFACE_ASSET, + 'surface' + ); + const overviewMaxZoom = Number(virtual.overviewMaxZoom ?? DEFAULT_OVERVIEW_MAX_ZOOM); + const surfaceMaxZoom = Number(virtual.surfaceMaxZoom ?? DEFAULT_SURFACE_MAX_ZOOM); + const routingZoom = Number(virtual.routingZoom ?? DEFAULT_ROUTING_ZOOM); + const maxZoom = Number(virtual.maxZoom ?? DEFAULT_MAX_ZOOM); + + for (const [label, value] of Object.entries({ + overviewMaxZoom, + surfaceMaxZoom, + routingZoom, + maxZoom + })) { + if (!Number.isSafeInteger(value) || value < 0 || value > 22) { + throw new Error(`The worldwide manifest has an invalid ${label}.`); + } + } + if (overviewMaxZoom >= routingZoom) { + throw new Error('The worldwide overview must end before regional routing begins.'); + } + if (surfaceMaxZoom > maxZoom) { + throw new Error('The worldwide surface archive exceeds the virtual tileset maximum zoom.'); + } + + const regions = manifest.regions.map((region) => ({ + ...region, + asset: requireAssetName(region.asset, `regional (${region.id || 'unknown'})`) + })); + + return { + ...manifest, + regions, + virtualTiles: { + overviewAsset, + surfaceAsset, + overviewMaxZoom, + surfaceMaxZoom, + routingZoom, + maxZoom + } + }; +} + +export class WorldTileGateway { + constructor({ + manifestUrl, + releaseAssetUrl, + fetchImpl = fetch, + cacheBytes = Number(process.env.OCCUMED_TILE_CACHE_MAX_BYTES || DEFAULT_CACHE_BYTES) + }) { + this.manifestUrl = manifestUrl; + this.releaseAssetUrl = releaseAssetUrl; + this.fetchImpl = fetchImpl; + this.tileCache = new MemoryTileCache(cacheBytes); + this.directoryCache = new SharedPromiseCache(4096); + this.archives = new Map(); + this.inflight = new Map(); + this.manifestPromise = null; + } + + async loadManifest() { + if (!this.manifestPromise) { + this.manifestPromise = this.fetchImpl(this.manifestUrl, { + headers: { + Accept: 'application/json', + 'User-Agent': 'Occu-Med-Map/virtual-world-tiles' + }, + redirect: 'follow' + }).then(async (response) => { + if (!response.ok) { + throw new Error(`Worldwide manifest upstream returned ${response.status}.`); + } + const manifest = parseManifest(await response.json()); + return { + ...manifest, + routingIndex: new WorldTileRoutingIndex(manifest.regions, { + routingZoom: manifest.virtualTiles.routingZoom + }) + }; + }).catch((error) => { + this.manifestPromise = null; + throw error; + }); + } + return this.manifestPromise; + } + + archive(asset) { + let archive = this.archives.get(asset); + if (!archive) { + archive = new PMTiles( + new RetryingFetchSource(this.releaseAssetUrl(asset)), + this.directoryCache + ); + this.archives.set(asset, archive); + } + return archive; + } + + async readArchiveTile(asset, zoom, x, y) { + const result = await this.archive(asset).getZxy(zoom, x, y); + return result?.data ? Buffer.from(result.data) : null; + } + + async readSurfaceTile(manifest, zoom, x, y) { + const { + surfaceAsset, + surfaceMaxZoom + } = manifest.virtualTiles; + if (zoom <= surfaceMaxZoom) { + const payload = await this.readArchiveTile(surfaceAsset, zoom, x, y); + return payload + ? mergeVectorTiles([payload], { includeLayers: ['land'] }) + : EMPTY_MVT; + } + + const divisor = 2 ** (zoom - surfaceMaxZoom); + const sourceX = Math.floor(x / divisor); + const sourceY = Math.floor(y / divisor); + const payload = await this.readArchiveTile( + surfaceAsset, + surfaceMaxZoom, + sourceX, + sourceY + ); + if (!payload) return EMPTY_MVT; + + return overscaleVectorLayer(payload, { + layerName: 'land', + sourceZoom: surfaceMaxZoom, + targetZoom: zoom, + targetX: x, + targetY: y + }); + } + + async readBasemapTile(manifest, zoom, x, y) { + const { + overviewAsset, + overviewMaxZoom + } = manifest.virtualTiles; + if (zoom <= overviewMaxZoom) { + const payload = await this.readArchiveTile(overviewAsset, zoom, x, y); + if (!payload) { + throw new Error(`Worldwide overview is missing tile ${zoom}/${x}/${y}.`); + } + return payload; + } + + const regions = manifest.routingIndex.regionsForTile(zoom, x, y); + if (!regions.length) return EMPTY_MVT; + + const payloads = await Promise.all( + regions.map((region) => this.readArchiveTile(region.asset, zoom, x, y)) + ); + return mergeVectorTiles(payloads); + } + + async resolveTile(zoom, x, y) { + const key = `${zoom}/${x}/${y}`; + const cached = this.tileCache.get(key); + if (cached) return cached; + if (this.inflight.has(key)) return this.inflight.get(key); + + const promise = this.loadManifest() + .then(async (manifest) => { + const [surface, basemap] = await Promise.all([ + this.readSurfaceTile(manifest, zoom, x, y), + this.readBasemapTile(manifest, zoom, x, y) + ]); + return this.tileCache.set( + key, + mergeVectorTiles([surface, basemap], { coordinateScale: 128 }) + ); + }) + .finally(() => this.inflight.delete(key)); + + this.inflight.set(key, promise); + return promise; + } +} diff --git a/src/server/world-tile-routing.js b/src/server/world-tile-routing.js new file mode 100644 index 00000000..b72d5ee4 --- /dev/null +++ b/src/server/world-tile-routing.js @@ -0,0 +1,128 @@ +const WEB_MERCATOR_LIMIT = 85.0511287798066; + +function clamp(value, minimum, maximum) { + return Math.min(Math.max(value, minimum), maximum); +} + +function normalizeLongitude(longitude) { + if (longitude === 180) return 180; + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + +function longitudeToTileX(longitude, zoom) { + const count = 2 ** zoom; + const normalized = clamp(longitude, -180, 180); + return clamp(Math.floor(((normalized + 180) / 360) * count), 0, count - 1); +} + +function latitudeToTileY(latitude, zoom) { + const count = 2 ** zoom; + const normalized = clamp(latitude, -WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT); + const radians = normalized * Math.PI / 180; + const value = (1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2; + return clamp(Math.floor(value * count), 0, count - 1); +} + +function tileLatitude(y, zoom) { + const mercator = Math.PI * (1 - (2 * y) / (2 ** zoom)); + return Math.atan(Math.sinh(mercator)) * 180 / Math.PI; +} + +export function normalizeTileCoordinates(zoom, x, y, maximumZoom = 16) { + const parsed = [zoom, x, y].map(Number); + if (!parsed.every(Number.isSafeInteger)) return null; + + const [z, tileX, tileY] = parsed; + if (z < 0 || z > maximumZoom) return null; + const count = 2 ** z; + if (tileX < 0 || tileX >= count || tileY < 0 || tileY >= count) return null; + return { z, x: tileX, y: tileY }; +} + +export function tileBounds(zoom, x, y) { + const count = 2 ** zoom; + return [ + (x / count) * 360 - 180, + tileLatitude(y + 1, zoom), + ((x + 1) / count) * 360 - 180, + tileLatitude(y, zoom) + ]; +} + +function regionSegments(bounds) { + if (!Array.isArray(bounds) || bounds.length !== 4) return []; + let [west, south, east, north] = bounds.map(Number); + if (![west, south, east, north].every(Number.isFinite)) return []; + + west = normalizeLongitude(west); + east = east === 180 ? 180 : normalizeLongitude(east); + south = clamp(south, -WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT); + north = clamp(north, -WEB_MERCATOR_LIMIT, WEB_MERCATOR_LIMIT); + if (south >= north) return []; + + return west <= east + ? [[west, south, east, north]] + : [[west, south, 180, north], [-180, south, east, north]]; +} + +function intersects(left, right) { + const epsilon = 1e-10; + return ( + left[0] < right[2] - epsilon && + left[2] > right[0] + epsilon && + left[1] < right[3] - epsilon && + left[3] > right[1] + epsilon + ); +} + +function cellKey(x, y) { + return `${x}/${y}`; +} + +export class WorldTileRoutingIndex { + constructor(regions, { routingZoom = 6 } = {}) { + this.routingZoom = routingZoom; + this.regions = (regions || []).map((region) => ({ + ...region, + segments: regionSegments(region.bounds) + })); + this.cells = new Map(); + + for (const region of this.regions) { + for (const [west, south, east, north] of region.segments) { + const minX = longitudeToTileX(west, routingZoom); + const maxX = longitudeToTileX( + east === 180 ? 180 : Math.max(west, east - 1e-10), + routingZoom + ); + const minY = latitudeToTileY(north, routingZoom); + const maxY = latitudeToTileY(south, routingZoom); + + for (let x = minX; x <= maxX; x += 1) { + for (let y = minY; y <= maxY; y += 1) { + const key = cellKey(x, y); + const entries = this.cells.get(key) || []; + if (!entries.includes(region)) entries.push(region); + this.cells.set(key, entries); + } + } + } + } + } + + regionsForTile(zoom, x, y) { + const bounds = tileBounds(zoom, x, y); + let candidates = this.regions; + + if (zoom >= this.routingZoom) { + const divisor = 2 ** (zoom - this.routingZoom); + const cellX = Math.floor(x / divisor); + const cellY = Math.floor(y / divisor); + candidates = this.cells.get(cellKey(cellX, cellY)) || []; + } + + return candidates.filter((region) => + region.segments.some((segment) => intersects(segment, bounds)) + ); + } +} diff --git a/src/world-pmtiles-router.js b/src/world-pmtiles-router.js deleted file mode 100644 index f5e7a543..00000000 --- a/src/world-pmtiles-router.js +++ /dev/null @@ -1,103 +0,0 @@ -const DEFAULT_SWITCH_ZOOM = 6; -const ROUTER_SOURCE_ID = 'occumed-open'; - -function normalizePmtilesUrl(url) { - if (!url) return null; - return url.startsWith('pmtiles://') ? url : `pmtiles://${url}`; -} - -function containsCoordinate(bounds, longitude, latitude) { - if (!Array.isArray(bounds) || bounds.length !== 4) return false; - const [west, south, east, north] = bounds.map(Number); - if (![west, south, east, north].every(Number.isFinite)) return false; - const latitudeMatch = latitude >= south && latitude <= north; - const longitudeMatch = west <= east - ? longitude >= west && longitude <= east - : longitude >= west || longitude <= east; - return latitudeMatch && longitudeMatch; -} - -function boundsArea(bounds) { - if (!Array.isArray(bounds) || bounds.length !== 4) return Infinity; - const [west, south, east, north] = bounds.map(Number); - const width = west <= east ? east - west : 360 - west + east; - return Math.max(width, 0) * Math.max(north - south, 0); -} - -function selectRegion(regions, longitude, latitude) { - return regions - .filter((region) => containsCoordinate(region.bounds, longitude, latitude)) - .sort((left, right) => boundsArea(left.bounds) - boundsArea(right.bounds))[0] || null; -} - -async function fetchManifest(manifestUrl) { - const response = await fetch(manifestUrl, { - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' } - }); - if (!response.ok) throw new Error(`Unable to load worldwide PMTiles manifest (${response.status}).`); - const manifest = await response.json(); - if (!Array.isArray(manifest.regions)) throw new Error('Worldwide PMTiles manifest is missing regions.'); - return manifest; -} - -export async function installWorldPmtilesRouter(map, { - manifestUrl, - fallbackUrl, - sourceId = ROUTER_SOURCE_ID -}) { - if (!manifestUrl || !fallbackUrl) return null; - - let manifest; - try { - manifest = await fetchManifest(manifestUrl); - } catch (error) { - console.warn('Occu-Med worldwide PMTiles routing is unavailable; retaining the global fallback.', error); - return null; - } - - const switchZoom = Number.isFinite(Number(manifest.switchZoom)) - ? Number(manifest.switchZoom) - : DEFAULT_SWITCH_ZOOM; - let activeUrl = fallbackUrl; - let activeRegion = null; - - const apply = () => { - const source = map.getSource(sourceId); - if (!source || typeof source.setUrl !== 'function') return; - - const center = map.getCenter(); - const region = map.getZoom() >= switchZoom - ? selectRegion(manifest.regions, center.lng, center.lat) - : null; - const nextUrl = region ? normalizePmtilesUrl(region.url) : fallbackUrl; - if (!nextUrl || nextUrl === activeUrl) return; - - source.setUrl(nextUrl); - activeUrl = nextUrl; - activeRegion = region?.id || null; - map.fire('occumedworldsourcechange', { - region: activeRegion, - url: nextUrl, - fallback: !region - }); - }; - - const scheduleApply = () => queueMicrotask(apply); - map.on('moveend', scheduleApply); - map.on('zoomend', scheduleApply); - if (map.loaded()) apply(); - else map.once('load', apply); - - return { - manifest, - get activeRegion() { - return activeRegion; - }, - refresh: apply, - destroy() { - map.off('moveend', scheduleApply); - map.off('zoomend', scheduleApply); - } - }; -} diff --git a/visual-validation/fresno-pmtiles/README.md b/visual-validation/fresno-pmtiles/README.md deleted file mode 100644 index da962a54..00000000 --- a/visual-validation/fresno-pmtiles/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Fresno custom PMTiles visual evidence - -These files were generated from the real Occu-Med Planetiler archive, served by the production Node server with HTTP byte ranges, and rendered in headless Chromium at device scale factor 2. - -- `fresno-regional-z8.png` -- `fresno-city-z11.png` -- `fresno-street-z14.png` -- `sierra-terrain-z10.png` -- `preview-report.json` -- `occumed-fresno.manifest.txt` -- `pmtiles-range.headers.txt` - -This evidence validates the Fresno/central-Sierra regional archive only. It is not evidence of full-world coverage, global Natural Earth generalization, GEBCO bathymetry, generated contours, Overture enrichment, or complete route shields. diff --git a/visual-validation/fresno-pmtiles/occumed-fresno.manifest.txt b/visual-validation/fresno-pmtiles/occumed-fresno.manifest.txt deleted file mode 100644 index ed1a0de6..00000000 --- a/visual-validation/fresno-pmtiles/occumed-fresno.manifest.txt +++ /dev/null @@ -1,7 +0,0 @@ -commit=c9ce538093836aa5e071b3d7c7404bfc9e701b78 -source=https://download.geofabrik.de/north-america/us/california-latest.osm.pbf -bounds=-120.55,35.50,-118.45,37.55 -profile=planetiler/occumed-basemap.yml -built_at=2026-07-26T09:08:52Z -size_bytes=196398761 -431597c5bf19dc2c88dd109ac7478e0ae466a7343a5ce4813c29314b41379aa7 /home/runner/work/Map/Map/artifacts/occumed-fresno.pmtiles diff --git a/visual-validation/fresno-pmtiles/pmtiles-range.headers.txt b/visual-validation/fresno-pmtiles/pmtiles-range.headers.txt deleted file mode 100644 index ed40134f..00000000 --- a/visual-validation/fresno-pmtiles/pmtiles-range.headers.txt +++ /dev/null @@ -1,18 +0,0 @@ -HTTP/1.1 206 Partial Content -Access-Control-Allow-Origin: * -Access-Control-Allow-Methods: GET, HEAD, OPTIONS -Access-Control-Allow-Headers: Content-Type, Cache-Control, Pragma, Range -Access-Control-Expose-Headers: Accept-Ranges, Content-Length, Content-Range -Cross-Origin-Resource-Policy: cross-origin -X-Content-Type-Options: nosniff -Accept-Ranges: bytes -Cache-Control: public, max-age=3600, must-revalidate -CDN-Cache-Control: public, max-age=3600, must-revalidate -Surrogate-Control: public, max-age=3600, must-revalidate -Content-Length: 127 -Content-Range: bytes 0-126/196398761 -Content-Type: application/vnd.pmtiles -Date: Sun, 26 Jul 2026 09:06:41 GMT -Connection: keep-alive -Keep-Alive: timeout=5 - diff --git a/visual-validation/fresno-pmtiles/preview-report.json b/visual-validation/fresno-pmtiles/preview-report.json deleted file mode 100644 index 83674d9f..00000000 --- a/visual-validation/fresno-pmtiles/preview-report.json +++ /dev/null @@ -1,1845 +0,0 @@ -{ - "generatedAt": "2026-07-26T09:08:52.743Z", - "origin": "http://127.0.0.1:4173", - "browser": "chromium", - "viewport": { - "width": 1440, - "height": 1000, - "deviceScaleFactor": 2 - }, - "views": { - "fresnoRegional": { - "center": [ - -119.55000000000001, - 36.75 - ], - "zoom": 8, - "source": { - "type": "vector", - "url": "pmtiles://http://127.0.0.1:4173/tiles/occumed-fresno.pmtiles", - "attribution": "© OpenStreetMap contributors" - }, - "canvas": { - "cssWidth": 1440, - "cssHeight": 1000, - "backingWidth": 2880, - "backingHeight": 2000, - "effectivePixelRatio": 2 - }, - "loaded": true, - "tilesLoaded": true, - "visibleLayers": [ - "land", - "occumed-shaded-relief", - "landcover", - "national-park", - "national-park_tint-band", - "landuse", - "pitch-outline", - "waterway-shadow", - "water-shadow", - "waterway", - "water", - "wetland", - "wetland-pattern", - "occumed-hillshade", - "land-structure-polygon", - "land-structure-line", - "aeroway-polygon", - "aeroway-line", - "building", - "building-underground", - "tunnel-minor-case", - "tunnel-street-case", - "tunnel-minor-link-case", - "tunnel-secondary-tertiary-case", - "tunnel-primary-case", - "tunnel-major-link-case", - "tunnel-motorway-trunk-case", - "tunnel-path-trail", - "tunnel-path-cycleway-piste", - "tunnel-path", - "tunnel-steps", - "tunnel-pedestrian", - "tunnel-construction", - "tunnel-minor", - "tunnel-minor-link", - "tunnel-major-link", - "tunnel-street", - "tunnel-street-low", - "tunnel-secondary-tertiary", - "tunnel-primary", - "tunnel-motorway-trunk", - "tunnel-oneway-arrow-blue", - "tunnel-oneway-arrow-white", - "cliff", - "ferry", - "ferry-auto", - "road-pedestrian-polygon-fill", - "road-pedestrian-polygon-pattern", - "road-path-bg", - "road-steps-bg", - "road-pedestrian-case", - "road-path-trail", - "road-path-cycleway-piste", - "road-path", - "road-steps", - "road-pedestrian", - "golf-hole-line", - "road-polygon", - "turning-feature-outline", - "road-minor-case", - "road-street-case", - "road-minor-link-case", - "road-secondary-tertiary-case", - "road-primary-case", - "road-major-link-case", - "road-motorway-trunk-case", - "turning-feature", - "road-construction", - "road-minor", - "road-minor-link", - "road-major-link", - "road-street", - "road-street-low", - "road-secondary-tertiary", - "road-primary", - "road-motorway-trunk", - "road-rail", - "road-rail-tracks", - "level-crossing", - "road-oneway-arrow-blue", - "road-oneway-arrow-white", - "crosswalks", - "gate-fence-hedge", - "bridge-path-bg", - "bridge-steps-bg", - "bridge-pedestrian-case", - "bridge-path-trail", - "bridge-path-cycleway-piste", - "bridge-path", - "bridge-steps", - "bridge-pedestrian", - "gate-label", - "bridge-minor-case", - "bridge-street-case", - "bridge-minor-link-case", - "bridge-secondary-tertiary-case", - "bridge-primary-case", - "bridge-major-link-case", - "bridge-motorway-trunk-case", - "bridge-construction", - "bridge-minor", - "bridge-minor-link", - "bridge-major-link", - "bridge-street", - "bridge-street-low", - "bridge-secondary-tertiary", - "bridge-primary", - "bridge-motorway-trunk", - "bridge-major-link-2-case", - "bridge-motorway-trunk-2-case", - "bridge-major-link-2", - "bridge-motorway-trunk-2", - "bridge-oneway-arrow-blue", - "bridge-oneway-arrow-white", - "bridge-rail", - "bridge-rail-tracks", - "aerialway", - "admin-1-boundary-bg", - "admin-0-boundary-bg", - "admin-1-boundary", - "admin-0-boundary", - "admin-0-boundary-disputed", - "building-entrance", - "building-number-label", - "block-number-label", - "road-label", - "road-intersection", - "road-number-shield", - "road-exit-shield", - "path-pedestrian-label", - "golf-hole-label", - "ferry-aerialway-label", - "waterway-label", - "natural-line-label", - "natural-point-label", - "water-line-label", - "water-point-label", - "poi-label", - "transit-label", - "airport-label", - "settlement-subdivision-label", - "settlement-minor-label", - "settlement-major-label", - "state-label", - "country-label", - "continent-label" - ] - }, - "fresnoCity": { - "center": [ - -119.78710000000001, - 36.7378 - ], - "zoom": 11, - "source": { - "type": "vector", - "url": "pmtiles://http://127.0.0.1:4173/tiles/occumed-fresno.pmtiles", - "attribution": "© OpenStreetMap contributors" - }, - "canvas": { - "cssWidth": 1440, - "cssHeight": 1000, - "backingWidth": 2880, - "backingHeight": 2000, - "effectivePixelRatio": 2 - }, - "loaded": true, - "tilesLoaded": true, - "visibleLayers": [ - "land", - "occumed-shaded-relief", - "landcover", - "national-park", - "national-park_tint-band", - "landuse", - "pitch-outline", - "waterway-shadow", - "water-shadow", - "waterway", - "water", - "wetland", - "wetland-pattern", - "occumed-hillshade", - "land-structure-polygon", - "land-structure-line", - "aeroway-polygon", - "aeroway-line", - "building", - "building-underground", - "tunnel-minor-case", - "tunnel-street-case", - "tunnel-minor-link-case", - "tunnel-secondary-tertiary-case", - "tunnel-primary-case", - "tunnel-major-link-case", - "tunnel-motorway-trunk-case", - "tunnel-path-trail", - "tunnel-path-cycleway-piste", - "tunnel-path", - "tunnel-steps", - "tunnel-pedestrian", - "tunnel-construction", - "tunnel-minor", - "tunnel-minor-link", - "tunnel-major-link", - "tunnel-street", - "tunnel-street-low", - "tunnel-secondary-tertiary", - "tunnel-primary", - "tunnel-motorway-trunk", - "tunnel-oneway-arrow-blue", - "tunnel-oneway-arrow-white", - "cliff", - "ferry", - "ferry-auto", - "road-pedestrian-polygon-fill", - "road-pedestrian-polygon-pattern", - "road-path-bg", - "road-steps-bg", - "road-pedestrian-case", - "road-path-trail", - "road-path-cycleway-piste", - "road-path", - "road-steps", - "road-pedestrian", - "golf-hole-line", - "road-polygon", - "turning-feature-outline", - "road-minor-case", - "road-street-case", - "road-minor-link-case", - "road-secondary-tertiary-case", - "road-primary-case", - "road-major-link-case", - "road-motorway-trunk-case", - "turning-feature", - "road-construction", - "road-minor", - "road-minor-link", - "road-major-link", - "road-street", - "road-street-low", - "road-secondary-tertiary", - "road-primary", - "road-motorway-trunk", - "road-rail", - "road-rail-tracks", - "level-crossing", - "road-oneway-arrow-blue", - "road-oneway-arrow-white", - "crosswalks", - "gate-fence-hedge", - "bridge-path-bg", - "bridge-steps-bg", - "bridge-pedestrian-case", - "bridge-path-trail", - "bridge-path-cycleway-piste", - "bridge-path", - "bridge-steps", - "bridge-pedestrian", - "gate-label", - "bridge-minor-case", - "bridge-street-case", - "bridge-minor-link-case", - "bridge-secondary-tertiary-case", - "bridge-primary-case", - "bridge-major-link-case", - "bridge-motorway-trunk-case", - "bridge-construction", - "bridge-minor", - "bridge-minor-link", - "bridge-major-link", - "bridge-street", - "bridge-street-low", - "bridge-secondary-tertiary", - "bridge-primary", - "bridge-motorway-trunk", - "bridge-major-link-2-case", - "bridge-motorway-trunk-2-case", - "bridge-major-link-2", - "bridge-motorway-trunk-2", - "bridge-oneway-arrow-blue", - "bridge-oneway-arrow-white", - "bridge-rail", - "bridge-rail-tracks", - "aerialway", - "admin-1-boundary-bg", - "admin-0-boundary-bg", - "admin-1-boundary", - "admin-0-boundary", - "admin-0-boundary-disputed", - "building-entrance", - "building-number-label", - "block-number-label", - "road-label", - "road-intersection", - "road-number-shield", - "road-exit-shield", - "path-pedestrian-label", - "golf-hole-label", - "ferry-aerialway-label", - "waterway-label", - "natural-line-label", - "natural-point-label", - "water-line-label", - "water-point-label", - "poi-label", - "transit-label", - "airport-label", - "settlement-subdivision-label", - "settlement-minor-label", - "settlement-major-label", - "state-label", - "country-label", - "continent-label" - ] - }, - "fresnoStreet": { - "center": [ - -119.78710000000001, - 36.7378 - ], - "zoom": 14, - "source": { - "type": "vector", - "url": "pmtiles://http://127.0.0.1:4173/tiles/occumed-fresno.pmtiles", - "attribution": "© OpenStreetMap contributors" - }, - "canvas": { - "cssWidth": 1440, - "cssHeight": 1000, - "backingWidth": 2880, - "backingHeight": 2000, - "effectivePixelRatio": 2 - }, - "loaded": true, - "tilesLoaded": true, - "visibleLayers": [ - "land", - "occumed-shaded-relief", - "landcover", - "national-park", - "national-park_tint-band", - "landuse", - "pitch-outline", - "waterway-shadow", - "water-shadow", - "waterway", - "water", - "wetland", - "wetland-pattern", - "occumed-hillshade", - "land-structure-polygon", - "land-structure-line", - "aeroway-polygon", - "aeroway-line", - "building", - "building-underground", - "tunnel-minor-case", - "tunnel-street-case", - "tunnel-minor-link-case", - "tunnel-secondary-tertiary-case", - "tunnel-primary-case", - "tunnel-major-link-case", - "tunnel-motorway-trunk-case", - "tunnel-path-trail", - "tunnel-path-cycleway-piste", - "tunnel-path", - "tunnel-steps", - "tunnel-pedestrian", - "tunnel-construction", - "tunnel-minor", - "tunnel-minor-link", - "tunnel-major-link", - "tunnel-street", - "tunnel-street-low", - "tunnel-secondary-tertiary", - "tunnel-primary", - "tunnel-motorway-trunk", - "tunnel-oneway-arrow-blue", - "tunnel-oneway-arrow-white", - "cliff", - "ferry", - "ferry-auto", - "road-pedestrian-polygon-fill", - "road-pedestrian-polygon-pattern", - "road-path-bg", - "road-steps-bg", - "road-pedestrian-case", - "road-path-trail", - "road-path-cycleway-piste", - "road-path", - "road-steps", - "road-pedestrian", - "golf-hole-line", - "road-polygon", - "turning-feature-outline", - "road-minor-case", - "road-street-case", - "road-minor-link-case", - "road-secondary-tertiary-case", - "road-primary-case", - "road-major-link-case", - "road-motorway-trunk-case", - "turning-feature", - "road-construction", - "road-minor", - "road-minor-link", - "road-major-link", - "road-street", - "road-street-low", - "road-secondary-tertiary", - "road-primary", - "road-motorway-trunk", - "road-rail", - "road-rail-tracks", - "level-crossing", - "road-oneway-arrow-blue", - "road-oneway-arrow-white", - "crosswalks", - "gate-fence-hedge", - "bridge-path-bg", - "bridge-steps-bg", - "bridge-pedestrian-case", - "bridge-path-trail", - "bridge-path-cycleway-piste", - "bridge-path", - "bridge-steps", - "bridge-pedestrian", - "gate-label", - "bridge-minor-case", - "bridge-street-case", - "bridge-minor-link-case", - "bridge-secondary-tertiary-case", - "bridge-primary-case", - "bridge-major-link-case", - "bridge-motorway-trunk-case", - "bridge-construction", - "bridge-minor", - "bridge-minor-link", - "bridge-major-link", - "bridge-street", - "bridge-street-low", - "bridge-secondary-tertiary", - "bridge-primary", - "bridge-motorway-trunk", - "bridge-major-link-2-case", - "bridge-motorway-trunk-2-case", - "bridge-major-link-2", - "bridge-motorway-trunk-2", - "bridge-oneway-arrow-blue", - "bridge-oneway-arrow-white", - "bridge-rail", - "bridge-rail-tracks", - "aerialway", - "admin-1-boundary-bg", - "admin-0-boundary-bg", - "admin-1-boundary", - "admin-0-boundary", - "admin-0-boundary-disputed", - "building-entrance", - "building-number-label", - "block-number-label", - "road-label", - "road-intersection", - "road-number-shield", - "road-exit-shield", - "path-pedestrian-label", - "golf-hole-label", - "ferry-aerialway-label", - "waterway-label", - "natural-line-label", - "natural-point-label", - "water-line-label", - "water-point-label", - "poi-label", - "transit-label", - "airport-label", - "settlement-subdivision-label", - "settlement-minor-label", - "settlement-major-label", - "state-label", - "country-label", - "continent-label" - ] - }, - "sierraTerrain": { - "center": [ - -118.85, - 36.85 - ], - "zoom": 10, - "source": { - "type": "vector", - "url": "pmtiles://http://127.0.0.1:4173/tiles/occumed-fresno.pmtiles", - "attribution": "© OpenStreetMap contributors" - }, - "canvas": { - "cssWidth": 1440, - "cssHeight": 1000, - "backingWidth": 2880, - "backingHeight": 2000, - "effectivePixelRatio": 2 - }, - "loaded": true, - "tilesLoaded": true, - "visibleLayers": [ - "land", - "occumed-shaded-relief", - "landcover", - "national-park", - "national-park_tint-band", - "landuse", - "pitch-outline", - "waterway-shadow", - "water-shadow", - "waterway", - "water", - "wetland", - "wetland-pattern", - "occumed-hillshade", - "land-structure-polygon", - "land-structure-line", - "aeroway-polygon", - "aeroway-line", - "building", - "building-underground", - "tunnel-minor-case", - "tunnel-street-case", - "tunnel-minor-link-case", - "tunnel-secondary-tertiary-case", - "tunnel-primary-case", - "tunnel-major-link-case", - "tunnel-motorway-trunk-case", - "tunnel-path-trail", - "tunnel-path-cycleway-piste", - "tunnel-path", - "tunnel-steps", - "tunnel-pedestrian", - "tunnel-construction", - "tunnel-minor", - "tunnel-minor-link", - "tunnel-major-link", - "tunnel-street", - "tunnel-street-low", - "tunnel-secondary-tertiary", - "tunnel-primary", - "tunnel-motorway-trunk", - "tunnel-oneway-arrow-blue", - "tunnel-oneway-arrow-white", - "cliff", - "ferry", - "ferry-auto", - "road-pedestrian-polygon-fill", - "road-pedestrian-polygon-pattern", - "road-path-bg", - "road-steps-bg", - "road-pedestrian-case", - "road-path-trail", - "road-path-cycleway-piste", - "road-path", - "road-steps", - "road-pedestrian", - "golf-hole-line", - "road-polygon", - "turning-feature-outline", - "road-minor-case", - "road-street-case", - "road-minor-link-case", - "road-secondary-tertiary-case", - "road-primary-case", - "road-major-link-case", - "road-motorway-trunk-case", - "turning-feature", - "road-construction", - "road-minor", - "road-minor-link", - "road-major-link", - "road-street", - "road-street-low", - "road-secondary-tertiary", - "road-primary", - "road-motorway-trunk", - "road-rail", - "road-rail-tracks", - "level-crossing", - "road-oneway-arrow-blue", - "road-oneway-arrow-white", - "crosswalks", - "gate-fence-hedge", - "bridge-path-bg", - "bridge-steps-bg", - "bridge-pedestrian-case", - "bridge-path-trail", - "bridge-path-cycleway-piste", - "bridge-path", - "bridge-steps", - "bridge-pedestrian", - "gate-label", - "bridge-minor-case", - "bridge-street-case", - "bridge-minor-link-case", - "bridge-secondary-tertiary-case", - "bridge-primary-case", - "bridge-major-link-case", - "bridge-motorway-trunk-case", - "bridge-construction", - "bridge-minor", - "bridge-minor-link", - "bridge-major-link", - "bridge-street", - "bridge-street-low", - "bridge-secondary-tertiary", - "bridge-primary", - "bridge-motorway-trunk", - "bridge-major-link-2-case", - "bridge-motorway-trunk-2-case", - "bridge-major-link-2", - "bridge-motorway-trunk-2", - "bridge-oneway-arrow-blue", - "bridge-oneway-arrow-white", - "bridge-rail", - "bridge-rail-tracks", - "aerialway", - "admin-1-boundary-bg", - "admin-0-boundary-bg", - "admin-1-boundary", - "admin-0-boundary", - "admin-0-boundary-disputed", - "building-entrance", - "building-number-label", - "block-number-label", - "road-label", - "road-intersection", - "road-number-shield", - "road-exit-shield", - "path-pedestrian-label", - "golf-hole-label", - "ferry-aerialway-label", - "waterway-label", - "natural-line-label", - "natural-point-label", - "water-line-label", - "water-point-label", - "poi-label", - "transit-label", - "airport-label", - "settlement-subdivision-label", - "settlement-minor-label", - "settlement-major-label", - "state-label", - "country-label", - "continent-label" - ] - } - }, - "pageErrors": [], - "resourceFailures": [ - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0041 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0043 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0046 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0049 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0052 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0020 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0030 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0031 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0032 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0033 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0034 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0035 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0036 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0037 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0038 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0039 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0042 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0043 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0044 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0045 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0048 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0050 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0052 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0053 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0054 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0056 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0061 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0063 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0064 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0065 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0066 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0067 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0068 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0069 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0070 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0071 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0072 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0073 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0074 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0075 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0076 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0077 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0079 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+007A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0041 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0046 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0049 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0057 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0062 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0078 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0020 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0031 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0041 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0042 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0043 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0044 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0045 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0046 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0047 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0048 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0049 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0050 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0052 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0053 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0054 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0056 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0057 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0059 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0061 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0062 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0063 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0064 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0065 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0066 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0067 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0068 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0069 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0070 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0071 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0072 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0073 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0074 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0075 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0076 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0077 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0079 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+007A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0027 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0033 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0039 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0055 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0035 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0038 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+003B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0030 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0032 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0037 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 32, 8192-8447. Rendering codepoint U+2013 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/8192-8447.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0051 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0034 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0036 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+005A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0026 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - } - ], - "consoleMessages": [ - { - "type": "warning", - "text": "[GroupMarkerNotSet(crbug.com/242999)!:A0301F00143A0000]Automatic fallback to software WebGL has been deprecated. Please use the --enable-unsafe-swiftshader (about:flags#enable-unsafe-swiftshader) flag to opt in to lower security guarantees for trusted content." - }, - { - "type": "warning", - "text": "[.WebGL-0x1aec000ea300]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): GPU stall due to ReadPixels" - }, - { - "type": "warning", - "text": "[.WebGL-0x1aec000ea300]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): GPU stall due to ReadPixels" - }, - { - "type": "warning", - "text": "[.WebGL-0x1aec000ea300]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): GPU stall due to ReadPixels" - }, - { - "type": "warning", - "text": "[.WebGL-0x1aec000ea300]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): GPU stall due to ReadPixels (this message will no longer repeat)" - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0041 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0043 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0046 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0049 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0052 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Semibold,Noto Sans Semibold/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0020 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0030 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0031 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0032 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0033 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0034 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0035 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0036 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0037 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0038 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0039 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0042 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0043 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0044 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0045 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0048 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0050 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0052 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0053 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0054 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0056 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0061 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0063 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0064 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0065 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0066 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0067 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0068 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0069 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0070 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0071 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0072 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0073 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0074 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0075 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0076 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0077 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0079 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+007A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0041 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0046 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0049 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0057 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0062 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0078 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Italic,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "error", - "text": "Failed to load resource: the server responded with a status of 404 ()" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0020 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0031 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0041 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0042 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0043 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0044 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0045 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0046 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0047 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0048 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0049 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+004F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0050 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0052 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0053 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0054 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0056 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0057 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0059 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0061 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0062 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0063 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0064 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0065 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0066 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0067 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0068 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0069 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006C locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006E locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+006F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0070 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0071 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0072 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0073 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0074 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0075 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0076 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0077 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0079 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+007A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0027 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0033 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0039 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0055 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0035 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0038 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+003B locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002F locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0030 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0032 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0037 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 32, 8192-8447. Rendering codepoint U+2013 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/8192-8447.pbf" - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Expected value to be of type number, but found null instead." - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0051 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0034 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0036 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+002D locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+005A locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - }, - { - "type": "warning", - "text": "Unable to load glyph range 0, 0-255. Rendering codepoint U+0026 locally instead. Error: AJAXError: (404): https://tiles.openfreemap.org/fonts/Open Sans Regular,Noto Sans Regular/0-255.pbf" - } - ] -} From 6cb52b84364dbe96b216bbccc77695752533ca6d Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 12:13:17 -0700 Subject: [PATCH 02/13] Repair missing shards before mosaic generation --- .../workflows/build-virtual-world-tileset.yml | 84 +++++++++++++++++++ scripts/build-world-overview.mjs | 13 ++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index bbce0642..9684f32c 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -33,7 +33,91 @@ env: PMTILES_VERSION: "v1.31.2" jobs: + plan-storage: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + matrix: ${{ steps.missing.outputs.matrix }} + missing_count: ${{ steps.missing.outputs.missing_count }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Resolve missing canonical storage shards + id: missing + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p virtual-world/preflight + node scripts/plan-world-shards-final.mjs \ + > virtual-world/preflight/world-plan.json + release_id="$(gh api "/repos/${GITHUB_REPOSITORY}/releases/tags/${WORLD_RELEASE_TAG}" --jq '.id')" + gh api --paginate --slurp \ + "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \ + > virtual-world/preflight/release-pages.json + + node <<'NODE' + const fs = require('node:fs'); + const plan = JSON.parse(fs.readFileSync('virtual-world/preflight/world-plan.json', 'utf8')); + const pages = JSON.parse(fs.readFileSync('virtual-world/preflight/release-pages.json', 'utf8')); + const assets = pages.flatMap((page) => page || []); + const sizes = new Map(assets.map((asset) => [asset.name, Number(asset.size || 0)])); + const missing = plan.include.filter((region) => (sizes.get(region.asset_name) || 0) <= 0); + const include = missing.length ? missing : [{ + skip: true, + id: 'nothing-missing', + slug: 'nothing-missing', + name: 'Nothing missing', + continent: 'none', + pbf_url: 'https://example.invalid/nothing.osm.pbf', + extract_bbox: '', + west: 0, + south: 0, + east: 0, + north: 0, + asset_name: 'nothing-missing.pmtiles' + }]; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include })}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `missing_count=${missing.length}\n`); + console.log(`Canonical storage: ${plan.include.length - missing.length}/${plan.include.length}; repairing ${missing.length}.`); + for (const region of missing) console.log(`Missing: ${region.id} -> ${region.asset_name}`); + NODE + + repair-missing: + needs: plan-storage + runs-on: ubuntu-latest + timeout-minutes: 360 + strategy: + fail-fast: false + max-parallel: 8 + matrix: ${{ fromJson(needs.plan-storage.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/build-pmtiles-shard-final + if: matrix.skip != true + with: + id: ${{ matrix.id }} + slug: ${{ matrix.slug }} + name: ${{ matrix.name }} + continent: ${{ matrix.continent }} + pbf-url: ${{ matrix.pbf_url }} + extract-bbox: ${{ matrix.extract_bbox }} + west: ${{ matrix.west }} + south: ${{ matrix.south }} + east: ${{ matrix.east }} + north: ${{ matrix.north }} + asset-name: ${{ matrix.asset_name }} + release-tag: ${{ env.WORLD_RELEASE_TAG }} + build: + needs: + - plan-storage + - repair-missing runs-on: ubuntu-latest timeout-minutes: 180 steps: diff --git a/scripts/build-world-overview.mjs b/scripts/build-world-overview.mjs index 1338e578..d7cd2910 100644 --- a/scripts/build-world-overview.mjs +++ b/scripts/build-world-overview.mjs @@ -95,9 +95,16 @@ for (let zoom = 0; zoom <= options.maxZoom; zoom += 1) { for (let y = 0; y < count; y += 1) { const regions = routing.regionsForTile(zoom, x, y); const payloads = await mapLimit(regions, options.concurrency, async (region) => { - const result = await archive(region).getZxy(zoom, x, y); - sourceTileReads += 1; - return result?.data ? Buffer.from(result.data) : null; + try { + const result = await archive(region).getZxy(zoom, x, y); + sourceTileReads += 1; + return result?.data ? Buffer.from(result.data) : null; + } catch (error) { + throw new Error( + `Unable to read ${region.id} (${region.asset}) at ${zoom}/${x}/${y}: ${error.message}`, + { cause: error } + ); + } }); const merged = mergeVectorTiles(payloads); if (!merged.byteLength) continue; From fed42fc077a1dfe4e2a0fe60e15ebcc7e6fbf12f Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 12:16:10 -0700 Subject: [PATCH 03/13] Require healthy uploaded shard assets --- .../build-pmtiles-shard-final/action.yml | 27 ++++++++++++------- .github/workflows/audit-world-release-v2.yml | 4 +++ .../workflows/build-virtual-world-tileset.yml | 7 +++-- .github/workflows/clean-world-release.yml | 24 +++++++++++++---- .../repair-missing-world-pmtiles.yml | 15 ++++++++--- .github/workflows/repair-world-pmtiles.yml | 6 ++--- scripts/build-world-manifest.mjs | 14 +++++++--- 7 files changed, 71 insertions(+), 26 deletions(-) diff --git a/.github/actions/build-pmtiles-shard-final/action.yml b/.github/actions/build-pmtiles-shard-final/action.yml index fb9428fd..c10cc746 100644 --- a/.github/actions/build-pmtiles-shard-final/action.yml +++ b/.github/actions/build-pmtiles-shard-final/action.yml @@ -34,16 +34,21 @@ runs: > "$assets_json" row="$( jq -r --arg name "$ASSET_NAME" \ - '[.[][] | select(.name == $name)][0] | if . == null then "" else [.id, .name, .size] | @tsv end' \ + '[.[][] | select(.name == $name)][0] | if . == null then "" else [.id, .name, .size, .state] | @tsv end' \ "$assets_json" )" rm -f "$assets_json" asset_id="$(printf '%s' "$row" | cut -f1)" asset_size="$(printf '%s' "$row" | cut -f3)" - if [ -n "$asset_id" ] && [ -n "$asset_size" ] && [ "$asset_size" -gt 0 ]; then + asset_state="$(printf '%s' "$row" | cut -f4)" + if [ -n "$asset_id" ] && [ -n "$asset_size" ] && [ "$asset_size" -gt 0 ] && [ "$asset_state" = uploaded ]; then echo 'exists=true' >> "$GITHUB_OUTPUT" echo "Reusing published asset: $ASSET_NAME ($asset_size bytes)" else + if [ -n "$asset_id" ]; then + echo "Deleting incomplete release asset before rebuild: $ASSET_NAME ($asset_state, $asset_size bytes)" + gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" + fi echo 'exists=false' >> "$GITHUB_OUTPUT" fi @@ -113,14 +118,14 @@ runs: set -euo pipefail release_id="$(gh api "/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.id')" - lookup_size() { + lookup_asset() { local assets_json assets_json="$(mktemp)" gh api --paginate --slurp \ "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \ > "$assets_json" jq -r --arg name "$ASSET_NAME" \ - '[.[][] | select(.name == $name)][0] | if . == null then "" else (.size | tostring) end' \ + '[.[][] | select(.name == $name)][0] | if . == null then "" else [(.size | tostring), .state] | @tsv end' \ "$assets_json" rm -f "$assets_json" } @@ -132,17 +137,21 @@ runs: uploaded=true break fi - size="$(lookup_size)" - if [ -n "$size" ] && [ "$size" -gt 0 ]; then - echo "Asset appeared during upload: $ASSET_NAME ($size bytes)" + row="$(lookup_asset)" + size="$(printf '%s' "$row" | cut -f1)" + state="$(printf '%s' "$row" | cut -f2)" + if [ -n "$size" ] && [ "$size" -gt 0 ] && [ "$state" = uploaded ]; then + echo "Asset appeared during upload: $ASSET_NAME ($size bytes, $state)" uploaded=true break fi sleep $((attempt * 10)) done - size="$(lookup_size)" - if [ "$uploaded" != true ] || [ -z "$size" ] || [ "$size" -le 0 ]; then + row="$(lookup_asset)" + size="$(printf '%s' "$row" | cut -f1)" + state="$(printf '%s' "$row" | cut -f2)" + if [ "$uploaded" != true ] || [ -z "$size" ] || [ "$size" -le 0 ] || [ "$state" != uploaded ]; then echo "Release asset is still missing after upload: $ASSET_NAME" >&2 exit 1 fi diff --git a/.github/workflows/audit-world-release-v2.yml b/.github/workflows/audit-world-release-v2.yml index e3d2cabc..a0e383a2 100644 --- a/.github/workflows/audit-world-release-v2.yml +++ b/.github/workflows/audit-world-release-v2.yml @@ -75,6 +75,7 @@ jobs: const missing = regions.filter((region) => !byName.has(region.asset_name)); const zeroSize = regions.filter((region) => Number(byName.get(region.asset_name)?.size || 0) <= 0); + const incomplete = regions.filter((region) => byName.get(region.asset_name)?.state !== 'uploaded'); const extras = assets.filter((asset) => asset.name.endsWith('.pmtiles') && !plannedNameSet.has(asset.name) && @@ -128,6 +129,7 @@ jobs: publishedCanonicalCount: regions.length - missing.length, missingCanonicalCount: missing.length, zeroSizeCanonicalCount: zeroSize.length, + incompleteCanonicalCount: incomplete.length, extraPmtilesAssetCount: extras.length, manifestMissingRegionCount: Number(manifest.missingRegionCount), manifestPlannedRegionCount: Number(manifest.plannedRegionCount), @@ -141,11 +143,13 @@ jobs: structuralErrors, missing: missing.map((region) => ({ id: region.id, asset: region.asset_name })), zeroSize: zeroSize.map((region) => ({ id: region.id, asset: region.asset_name })), + incomplete: incomplete.map((region) => ({ id: region.id, asset: region.asset_name })), extraPmtilesAssets: extras.map((asset) => ({ name: asset.name, size: Number(asset.size || 0) })), binaryRangeAndRenderValidation: 'deferred_to_fresno_runtime_browser_validation', passed: missing.length === 0 && zeroSize.length === 0 && + incomplete.length === 0 && extras.length === 0 && absentFromManifest.length === 0 && unexpectedInManifest.length === 0 && diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index 9684f32c..7087d5f4 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -65,8 +65,11 @@ jobs: const plan = JSON.parse(fs.readFileSync('virtual-world/preflight/world-plan.json', 'utf8')); const pages = JSON.parse(fs.readFileSync('virtual-world/preflight/release-pages.json', 'utf8')); const assets = pages.flatMap((page) => page || []); - const sizes = new Map(assets.map((asset) => [asset.name, Number(asset.size || 0)])); - const missing = plan.include.filter((region) => (sizes.get(region.asset_name) || 0) <= 0); + const byName = new Map(assets.map((asset) => [asset.name, asset])); + const missing = plan.include.filter((region) => { + const asset = byName.get(region.asset_name); + return !asset || asset.state !== 'uploaded' || Number(asset.size || 0) <= 0; + }); const include = missing.length ? missing : [{ skip: true, id: 'nothing-missing', diff --git a/.github/workflows/clean-world-release.yml b/.github/workflows/clean-world-release.yml index 6b1da753..3b9a6977 100644 --- a/.github/workflows/clean-world-release.yml +++ b/.github/workflows/clean-world-release.yml @@ -87,15 +87,25 @@ jobs: 'occumed-world-overview.pmtiles', 'occumed-world-surface.pmtiles' ]); - const retained = releaseAssets.filter((asset) => protectedNames.has(asset.name) && asset.size > 0); + const retained = releaseAssets.filter((asset) => + protectedNames.has(asset.name) && + asset.state === 'uploaded' && + asset.size > 0 + ); const deleteTargets = releaseAssets - .filter((asset) => !protectedNames.has(asset.name) || asset.size <= 0) + .filter((asset) => + !protectedNames.has(asset.name) || + asset.state !== 'uploaded' || + asset.size <= 0 + ) .map((asset) => ({ id: asset.id, name: asset.name, sizeBytes: asset.size, - reason: protectedNames.has(asset.name) - ? 'zero_size_world_tileset_asset' + reason: protectedNames.has(asset.name) && asset.state !== 'uploaded' + ? 'incomplete_world_tileset_asset' + : protectedNames.has(asset.name) + ? 'zero_size_world_tileset_asset' : (asset.name.endsWith('.pmtiles') ? 'noncanonical_pmtiles' : 'noncanonical_release_asset') })); fs.writeFileSync( @@ -173,7 +183,11 @@ jobs: 'occumed-world-overview.pmtiles', 'occumed-world-surface.pmtiles' ]); - const unexpected = assets.filter((asset) => !protectedNames.has(asset.name) || asset.size <= 0); + const unexpected = assets.filter((asset) => + !protectedNames.has(asset.name) || + asset.state !== 'uploaded' || + asset.size <= 0 + ); if (unexpected.length) { throw new Error(`Cleanup left ${unexpected.length} unexpected or zero-byte release assets.`); } diff --git a/.github/workflows/repair-missing-world-pmtiles.yml b/.github/workflows/repair-missing-world-pmtiles.yml index c4d045af..8a808113 100644 --- a/.github/workflows/repair-missing-world-pmtiles.yml +++ b/.github/workflows/repair-missing-world-pmtiles.yml @@ -56,7 +56,13 @@ jobs: 'occumed-world-surface.pmtiles', ...(plan.include || []).map((region) => region.asset_name) ]); - const deletions = assets.filter((asset) => !keep.has(asset.name) || (asset.name !== 'world-manifest.json' && Number(asset.size || 0) <= 0)); + const deletions = assets.filter((asset) => + !keep.has(asset.name) || + ( + asset.name !== 'world-manifest.json' && + (asset.state !== 'uploaded' || Number(asset.size || 0) <= 0) + ) + ); fs.writeFileSync('/tmp/delete-release-assets.tsv', deletions.map((asset) => `${asset.id}\t${asset.name}\t${asset.size || 0}`).join('\n') + (deletions.length ? '\n' : '')); console.log(`Pinned canonical shard count: ${plan.include.length}`); console.log(`Noncanonical or zero-byte assets to delete: ${deletions.length}`); @@ -77,8 +83,11 @@ jobs: const plan = JSON.parse(fs.readFileSync('world-plan/world-plan.json', 'utf8')); const pages = JSON.parse(fs.readFileSync('world-plan/release-pages.json', 'utf8')); const assets = pages.flatMap((page) => page || []); - const published = new Map(assets.map((asset) => [asset.name, Number(asset.size || 0)])); - const missing = (plan.include || []).filter((region) => (published.get(region.asset_name) || 0) <= 0); + const published = new Map(assets.map((asset) => [asset.name, asset])); + const missing = (plan.include || []).filter((region) => { + const asset = published.get(region.asset_name); + return !asset || asset.state !== 'uploaded' || Number(asset.size || 0) <= 0; + }); const matrixInclude = missing.length ? missing : [{ skip: true, id: 'nothing-missing', diff --git a/.github/workflows/repair-world-pmtiles.yml b/.github/workflows/repair-world-pmtiles.yml index 7ed525cf..f0ca53ca 100644 --- a/.github/workflows/repair-world-pmtiles.yml +++ b/.github/workflows/repair-world-pmtiles.yml @@ -303,9 +303,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | mkdir -p world-release - gh release view "$WORLD_RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --json assets \ + release_id="$(gh api "/repos/${GITHUB_REPOSITORY}/releases/tags/${WORLD_RELEASE_TAG}" --jq '.id')" + gh api --paginate --slurp \ + "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \ > world-release/assets.json node scripts/build-world-manifest.mjs \ diff --git a/scripts/build-world-manifest.mjs b/scripts/build-world-manifest.mjs index b71236b5..3051579d 100644 --- a/scripts/build-world-manifest.mjs +++ b/scripts/build-world-manifest.mjs @@ -30,13 +30,19 @@ const [plan, assetsPayload] = await Promise.all([ const assets = Array.isArray(assetsPayload) ? assetsPayload.flatMap((page) => page || []) : assetsPayload.assets || []; -const assetNames = new Set(assets.map((asset) => asset.name)); +const healthyAssetNames = new Set( + assets + .filter((asset) => asset.state === 'uploaded' && Number(asset.size || 0) > 0) + .map((asset) => asset.name) +); const planned = plan.include || []; -const available = planned.filter((region) => assetNames.has(region.asset_name)); -const missing = planned.filter((region) => !assetNames.has(region.asset_name)); +const available = planned.filter((region) => healthyAssetNames.has(region.asset_name)); +const missing = planned.filter((region) => !healthyAssetNames.has(region.asset_name)); const overviewAsset = options.overviewAsset || 'occumed-world-overview.pmtiles'; const surfaceAsset = options.surfaceAsset || 'occumed-world-surface.pmtiles'; -const missingVirtualAssets = [overviewAsset, surfaceAsset].filter((asset) => !assetNames.has(asset)); +const missingVirtualAssets = [overviewAsset, surfaceAsset].filter( + (asset) => !healthyAssetNames.has(asset) +); if (missingVirtualAssets.length) { throw new Error( From 9677d77152046bc39969cb60cd15079dcb3b70ba Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 13:20:31 -0700 Subject: [PATCH 04/13] Finalize continuous worldwide terrain styling --- .../workflows/build-virtual-world-tileset.yml | 53 +++- README.md | 6 +- package-lock.json | 48 ++++ package.json | 1 + scripts/build-runtime-style.mjs | 6 +- scripts/build-world-manifest.mjs | 1 + scripts/capture-world-fresno-preview.mjs | 3 + scripts/check-exact-exported-swatches.mjs | 45 +++- scripts/check-globe-parity.mjs | 16 +- scripts/check-live-world-tiles.mjs | 3 + scripts/check-photo-reference.mjs | 17 +- scripts/check-pmtiles-integration.mjs | 7 +- scripts/check-viewer-quality.mjs | 8 +- scripts/check-world-tile-gateway.mjs | 25 +- scripts/lock-exact-exported-swatches.mjs | 69 +++++- scripts/lock-reference-atmosphere.mjs | 10 +- scripts/prepare-world-bathymetry.mjs | 58 +++++ scripts/reference-studio-swatches.mjs | 234 ++++++++++++++++++ src/server/world-tile-gateway.js | 2 +- src/styles.css | 6 +- 20 files changed, 574 insertions(+), 44 deletions(-) create mode 100644 scripts/prepare-world-bathymetry.mjs create mode 100644 scripts/reference-studio-swatches.mjs diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index 7087d5f4..dd58031b 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -11,12 +11,21 @@ on: - package-lock.json - package.json - planetiler/** + - style.json + - scripts/build-runtime-style.mjs - scripts/build-world-manifest.mjs - scripts/build-world-overview.mjs + - scripts/capture-world-fresno-preview.mjs + - scripts/prepare-world-bathymetry.mjs - scripts/check-live-world-tiles.mjs + - scripts/lock-exact-exported-swatches.mjs + - scripts/lock-reference-atmosphere.mjs - scripts/pack-mbtiles.py - scripts/plan-world-shards-final.mjs + - scripts/reference-studio-swatches.mjs + - src/occumed-map.js - src/server/** + - src/styles.css permissions: contents: write @@ -161,7 +170,7 @@ jobs: virtual-world/overview.mbtiles \ "virtual-world/${OVERVIEW_ASSET}" - - name: Build worldwide land surface + - name: Build worldwide physical surface run: | set -euo pipefail sudo apt-get update @@ -173,16 +182,28 @@ jobs: curl --fail --location --retry 5 \ --output virtual-world/ne_10m_land.geojson \ https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_land.geojson + mkdir -p virtual-world/bathymetry + for band in \ + A_10000 B_9000 C_8000 D_7000 E_6000 F_5000 \ + G_4000 H_3000 I_2000 J_1000 K_200 L_0; do + curl --fail --location --retry 5 \ + --output "virtual-world/bathymetry/ne_10m_bathymetry_${band}.geojson" \ + "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_bathymetry_${band}.geojson" + done + node scripts/prepare-world-bathymetry.mjs \ + --input-dir virtual-world/bathymetry \ + --output virtual-world/ne_10m_bathymetry.geojson virtual-world/tippecanoe/tippecanoe \ --force \ --minimum-zoom=0 \ --maximum-zoom=10 \ - --layer=land \ + --preserve-input-order \ --no-feature-limit \ --no-tile-size-limit \ - --detect-shared-borders \ - --output="virtual-world/${SURFACE_ASSET}" \ - virtual-world/ne_10m_land.geojson + --no-simplification-of-shared-nodes \ + -L land:virtual-world/ne_10m_land.geojson \ + -L depth:virtual-world/ne_10m_bathymetry.geojson \ + --output="virtual-world/${SURFACE_ASSET}" - name: Validate virtual archives run: | @@ -235,6 +256,26 @@ jobs: --manifest "https://github.com/${GITHUB_REPOSITORY}/releases/download/${WORLD_RELEASE_TAG}/world-virtual-manifest.json" \ --release-base "https://github.com/${GITHUB_REPOSITORY}/releases/download/${WORLD_RELEASE_TAG}" + - name: Install Chromium for visual validation + run: npx playwright install --with-deps chromium + + - name: Render globe, regional, and street references + run: | + set -euo pipefail + npm run build + node server.mjs > virtual-world/browser-server.log 2>&1 & + server_pid="$!" + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + for attempt in {1..60}; do + if curl --fail --silent http://127.0.0.1:4173/healthz > /dev/null; then + break + fi + sleep 1 + done + curl --fail http://127.0.0.1:4173/healthz + OCCUMED_PREVIEW_OUTPUT=virtual-world/visual-validation \ + node scripts/capture-world-fresno-preview.mjs + - uses: actions/upload-artifact@v4 with: name: occumed-virtual-world-${{ github.sha }} @@ -242,5 +283,7 @@ jobs: virtual-world/occumed-world-overview.pmtiles virtual-world/occumed-world-surface.pmtiles virtual-world/manifest/world-virtual-manifest.json + virtual-world/visual-validation + virtual-world/browser-server.log if-no-files-found: error retention-days: 14 diff --git a/README.md b/README.md index 58f0bc85..596ec160 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The goal is a close visual replica without a Mapbox token or Mapbox-hosted runti - MapLibre GL JS - one permanent MapLibre vector source at `/tiles/{z}/{x}/{y}.pbf` - a server-side virtual worldwide tileset backed by the 754 PMTiles storage archives -- one consolidated zoom 0–5 overview and one generalized worldwide land surface +- one consolidated zoom 0–5 overview and one generalized worldwide physical surface - open elevation data used only for hillshade, not as a second basemap - locally compiled sprites - browser-local glyph rendering @@ -38,6 +38,8 @@ The Node tile gateway resolves each Z/X/Y request on the server: - zoom 6–16 is resolved against every storage shard intersecting the requested tile; - boundary tiles are decoded, deduplicated by stable feature ID, and re-encoded as one MVT; - the worldwide `land` layer is merged into the same response at every zoom; +- nested Natural Earth bathymetry bands are served as the `depth` layer at globe + and regional zooms, then fade before detailed navigation zooms; - completed virtual tiles are held in a bounded in-memory cache and exposed with CDN cache headers. The PMTiles archives and routing manifest are storage implementation details. Their URLs never appear in the MapLibre style. @@ -108,5 +110,5 @@ The build verifies: - browser-side PMTiles routing, `source.setUrl()`, fallback URLs, and OpenFreeMap are absent; - the routing index includes every intersecting shard, including antimeridian segments; - duplicate features are removed while boundary geometry is preserved; -- the overview, land surface, regional merge, in-memory cache, and virtual release workflow remain wired; +- the overview, physical surface, regional merge, in-memory cache, and virtual release workflow remain wired; - no application-specific overlay data is included. diff --git a/package-lock.json b/package-lock.json index 10643a37..065d3364 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@chrispahm/spritezero": "8.1.0", "@maplibre/maplibre-gl-style-spec": "24.8.1", + "playwright": "1.55.0", "vite": "8.1.5" } }, @@ -1357,6 +1358,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pmtiles": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.1.tgz", diff --git a/package.json b/package.json index fbe4ef25..230f6417 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@chrispahm/spritezero": "8.1.0", "@maplibre/maplibre-gl-style-spec": "24.8.1", + "playwright": "1.55.0", "vite": "8.1.5" } } diff --git a/scripts/build-runtime-style.mjs b/scripts/build-runtime-style.mjs index 4d8b6b94..8ef6001e 100644 --- a/scripts/build-runtime-style.mjs +++ b/scripts/build-runtime-style.mjs @@ -236,6 +236,8 @@ function resolveTargetSourceLayer(layer) { return symbol ? 'water_name' : 'waterway'; case 'water': return symbol ? 'water_name' : 'water'; + case 'depth': + return 'depth'; case 'structure': return symbol ? 'transportation_name' : 'transportation'; case 'aeroway': @@ -307,7 +309,7 @@ for (const layer of original.layers || []) { source: 'occumed-open', 'source-layer': 'land', paint: { - 'fill-color': '#8FB86B', + 'fill-color': '#E0E0D1', 'fill-opacity': 1, 'fill-antialias': true }, @@ -345,7 +347,7 @@ for (const layer of original.layers || []) { continue; } - if (sourceLayer === 'contour' || sourceLayer === 'depth') { + if (sourceLayer === 'contour') { skippedLayers.push({ id: layer.id, reason: `no equivalent ${sourceLayer} layer in the public vector schema` }); continue; } diff --git a/scripts/build-world-manifest.mjs b/scripts/build-world-manifest.mjs index 3051579d..d3d76a62 100644 --- a/scripts/build-world-manifest.mjs +++ b/scripts/build-world-manifest.mjs @@ -63,6 +63,7 @@ const manifest = { overviewMaxZoom: 5, surfaceAsset, surfaceLayer: 'land', + surfaceLayers: ['land', 'depth'], surfaceMaxZoom: 10, routingZoom: 6, minZoom: 0, diff --git a/scripts/capture-world-fresno-preview.mjs b/scripts/capture-world-fresno-preview.mjs index e65669c9..b85adaec 100644 --- a/scripts/capture-world-fresno-preview.mjs +++ b/scripts/capture-world-fresno-preview.mjs @@ -144,6 +144,9 @@ try { usMexico: await capture('us-mexico-border-z7', [-106.5, 31.8], 7), europe: await capture('europe-boundaries-z6', [12, 50], 6), antimeridian: await capture('russia-antimeridian-z5', [178, 60], 5), + southeastAsia: await capture('southeast-asia-z4', [115, 7], 4), + sydneyRegional: await capture('sydney-regional-z8', [150.6, -33.7], 8.39), + sydneyStreet: await capture('sydney-street-z10', [151.056, -33.787], 10.22), fresnoCity: await capture('fresno-city-z11', [-119.7871, 36.7378], 11), fresnoStreet: await capture('fresno-street-z14', [-119.7871, 36.7378], 14) }; diff --git a/scripts/check-exact-exported-swatches.mjs b/scripts/check-exact-exported-swatches.mjs index 8a82b823..50a7711e 100644 --- a/scripts/check-exact-exported-swatches.mjs +++ b/scripts/check-exact-exported-swatches.mjs @@ -1,6 +1,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + REFERENCE_STUDIO_EXPRESSION_SWATCHES, + REFERENCE_STUDIO_SWATCH_GROUPS +} from './reference-studio-swatches.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const runtime = JSON.parse( @@ -15,8 +19,8 @@ const expect = (condition, message) => { const color = (id, property) => layer(id)?.paint?.[property]; const EXACT = { - ocean: '#4F9CD6', - land: '#8FB86B', + ocean: '#79BCEC', + land: '#E0E0D1', wood: '#83CC66CC', scrub: '#A3D48799', crop: '#D1DD8899', @@ -25,14 +29,22 @@ const EXACT = { fallback: '#A0D382', park: '#A5CC8E', pitch: '#A9DB70', - wetland: '#A4CAD6', + wetland: '#A5CAD6', water: '#79BCEC', - waterShadow: '#7293EE' + waterShadow: '#7293EE', + depthShallow: '#79BCEC59', + depthMid: '#5AACE759', + depthDeep: '#3B9DE359' }; expect(color('land', 'background-color') === EXACT.ocean, 'The permanent ocean background changed.'); expect(color('occumed-land-surface', 'fill-color') === EXACT.land, 'The saturated land surface changed.'); expect(color('water', 'fill-color') === EXACT.water, 'The inland-water blue changed.'); +const waterDepthColors = JSON.stringify(color('water-depth', 'fill-color') || []); +for (const value of [EXACT.depthShallow, EXACT.depthMid, EXACT.depthDeep]) { + expect(waterDepthColors.includes(value), `The bathymetry swatch ${value} changed.`); +} +expect(layer('water-depth')?.['source-layer'] === 'depth', 'The continuous bathymetry source layer changed.'); expect(color('waterway', 'line-color') === EXACT.water, 'The waterway blue changed.'); expect(color('water-shadow', 'fill-color') === EXACT.waterShadow, 'The water-shadow blue changed.'); expect(color('waterway-shadow', 'line-color') === EXACT.waterShadow, 'The waterway-shadow blue changed.'); @@ -42,6 +54,24 @@ expect(color('national-park', 'fill-color') === EXACT.park, 'The national-park g expect(color('national-park_tint-band', 'line-color') === EXACT.park, 'The park tint-band green changed.'); expect(color('pitch-outline', 'line-color') === EXACT.pitch, 'The pitch green changed.'); +for (const group of REFERENCE_STUDIO_SWATCH_GROUPS) { + for (const id of group.layers) { + expect( + color(id, group.property) === group.color, + `The supplied Studio swatch for ${id} changed from ${group.color}.` + ); + } +} + +for (const group of REFERENCE_STUDIO_EXPRESSION_SWATCHES) { + for (const id of group.layers) { + expect( + JSON.stringify(color(id, group.property) || []).includes(group.color), + `The ${id} expression lost the supplied Studio swatch ${group.color}.` + ); + } +} + const landcoverColors = JSON.stringify(color('landcover', 'fill-color') || []); for (const [name, value] of Object.entries({ wood: EXACT.wood, @@ -69,6 +99,11 @@ expect(runtime.metadata?.['occumed:reference-color-system'] === 'continuous-worl expect(runtime.metadata?.['occumed:raster-relief-disabled'] === true, 'Raster relief protection is missing.'); expect(runtime.metadata?.['occumed:layer-specific-palette'] === true, 'Layer-specific palette protection is missing.'); expect(runtime.metadata?.['occumed:high-dpi-vector-clarity'] === true, 'High-DPI clarity protection is missing.'); +expect( + runtime.metadata?.['occumed:palette-source'] === + 'supplied-mapbox-studio-screenshots-display-p3-to-srgb', + 'The supplied screenshot palette provenance is missing.' +); if (failures.length) { console.error('Continuous-world palette validation failed:'); @@ -76,4 +111,4 @@ if (failures.length) { process.exit(1); } -console.log('Continuous-world palette guarded: saturated green land, clear blue water, and no raster fallback.'); +console.log('Supplied Studio palette guarded by layer: exact sRGB colors, clear blue water, and no raster fallback.'); diff --git a/scripts/check-globe-parity.mjs b/scripts/check-globe-parity.mjs index cf0cf56a..88422b98 100644 --- a/scripts/check-globe-parity.mjs +++ b/scripts/check-globe-parity.mjs @@ -34,7 +34,7 @@ function expressionOutputs(expression) { if (runtime.projection?.type !== 'globe') fail('The reusable style must use globe projection.'); if (runtime.fog) fail('Mapbox fog must be translated instead of shipped to MapLibre unchanged.'); if (!runtime.sky) fail('The MapLibre sky/atmosphere configuration is missing.'); -if (runtime.sky?.['sky-color'] !== '#03070B') fail('The fixed dark-space hex changed.'); +if (runtime.sky?.['sky-color'] !== '#181A1D') fail('The fixed reference-space hex changed.'); if (runtime.sky?.['horizon-color'] !== '#F5FDFF') fail('The atmospheric horizon hex changed.'); if (runtime.sky?.['fog-color'] !== '#B8E6FF') fail('The cool outer-atmosphere hex changed.'); if (!runtime.sky?.['atmosphere-blend']) fail('The globe atmosphere blend is missing.'); @@ -61,8 +61,8 @@ if (horizonOutputs.at(-1) !== 0) { if (runtime.layers.some((candidate) => candidate.type === 'raster')) { fail('A raster fallback basemap was reintroduced.'); } -if (layer('land')?.paint?.['background-color'] !== '#4F9CD6') fail('The permanent ocean blue changed.'); -if (layer('occumed-land-surface')?.paint?.['fill-color'] !== '#8FB86B') fail('The saturated land green changed.'); +if (layer('land')?.paint?.['background-color'] !== '#79BCEC') fail('The supplied Studio ocean blue changed.'); +if (layer('occumed-land-surface')?.paint?.['fill-color'] !== '#E0E0D1') fail('The supplied Studio land base changed.'); const landcover = layer('landcover'); if ((landcover?.minzoom ?? 99) !== 0) fail('Landcover is unavailable at globe zoom.'); @@ -74,6 +74,14 @@ const water = layer('water'); if (water?.paint?.['fill-color'] !== '#79BCEC') fail('The exact exported water blue changed.'); if (water?.paint?.['fill-opacity'] !== 1) fail('Water is blending into the land background.'); +const waterDepth = layer('water-depth'); +if (waterDepth?.['source-layer'] !== 'depth') fail('The continuous vector bathymetry layer is missing.'); +if (waterDepth?.maxzoom !== 8) fail('Bathymetry does not fade before detailed navigation zooms.'); +const waterDepthColors = JSON.stringify(waterDepth?.paint?.['fill-color'] || []); +for (const value of ['#79BCEC59', '#5AACE759', '#3B9DE359']) { + if (!waterDepthColors.includes(value)) fail(`The exported bathymetry swatch ${value} is missing.`); +} + const hillshade = layer('occumed-hillshade'); if (!hillshade) fail('The open hillshade layer is missing.'); if (hillshade?.minzoom !== 1.5) fail('Hillshade begins too late to shape the globe.'); @@ -101,4 +109,4 @@ if (failures.length) { process.exit(1); } -console.log(`Globe parity validated: dark space, strong white-blue atmosphere, saturated green land, clear blue water, and ${allColors.size} structure-specific colors.`); +console.log(`Globe parity validated: dark space, strong white-blue atmosphere, layered land, clear blue water, and ${allColors.size} structure-specific colors.`); diff --git a/scripts/check-live-world-tiles.mjs b/scripts/check-live-world-tiles.mjs index f701908a..173e93a5 100644 --- a/scripts/check-live-world-tiles.mjs +++ b/scripts/check-live-world-tiles.mjs @@ -44,6 +44,9 @@ for (const check of checks) { if (!layers.land?.featureCount) { throw new Error(`${check.name}: continuous worldwide land layer is missing.`); } + if (check.name === 'globe' && !layers.depth?.featureCount) { + throw new Error(`${check.name}: continuous worldwide bathymetry layer is missing.`); + } if ( check.requireDetail && !Object.keys(layers).some((name) => name !== 'land') diff --git a/scripts/check-photo-reference.mjs b/scripts/check-photo-reference.mjs index 2c0bcac4..ad3ee7fc 100644 --- a/scripts/check-photo-reference.mjs +++ b/scripts/check-photo-reference.mjs @@ -40,18 +40,18 @@ function collectNonHexColors(value, colors = []) { } assert(runtime.projection?.type === 'globe', 'Photo-reference build must use globe projection.'); -assert(runtime.sky?.['sky-color'] === '#03070B', 'Photo-reference build must preserve dark outer space.'); +assert(runtime.sky?.['sky-color'] === '#181A1D', 'Photo-reference build must preserve the dark charcoal reference space.'); assert(runtime.sky?.['horizon-color'] === '#F5FDFF', 'The luminous globe rim is missing.'); assert(runtime.sky?.['fog-ground-blend'] === 0, 'Ground fog must remain disabled.'); assert(runtime.sky?.['horizon-fog-blend'] === 0, 'Horizon fog must remain disabled.'); assert(!runtime.light, 'Directional light must not be reintroduced.'); assert(!runtime.fog, 'Mapbox fog must not be copied into the MapLibre runtime.'); -assert(layer('land')?.paint?.['background-color'] === '#4F9CD6', 'The permanent ocean blue changed.'); -assert(layer('occumed-land-surface')?.paint?.['fill-color'] === '#8FB86B', 'The saturated land green changed.'); +assert(layer('land')?.paint?.['background-color'] === '#79BCEC', 'The supplied Studio ocean blue changed.'); +assert(layer('occumed-land-surface')?.paint?.['fill-color'] === '#E0E0D1', 'The supplied Studio land base changed.'); assert(layer('water')?.paint?.['fill-color'] === '#79BCEC', 'The exact exported water swatch changed.'); assert(layer('water-shadow')?.paint?.['fill-color'] === '#7293EE', 'The exact exported water-shadow swatch changed.'); -assert(layer('wetland')?.paint?.['fill-color'] === '#A4CAD6', 'The exact exported wetland swatch changed.'); +assert(layer('wetland')?.paint?.['fill-color'] === '#A5CAD6', 'The supplied Studio wetland swatch changed.'); assert(layer('national-park')?.paint?.['fill-color'] === '#A5CC8E', 'The exact exported national-park swatch changed.'); const landcover = layer('landcover'); @@ -63,6 +63,13 @@ assert((landcover?.minzoom ?? 99) === 0, 'Landcover must be available at globe z assert(JSON.stringify(landcover?.paint?.['fill-opacity'] || []).includes('1'), 'Landcover swatches are being weakened by zoom opacity.'); assert(layer('landuse')?.paint?.['fill-opacity'] === 1, 'Detailed landuse swatches are being weakened by extra opacity.'); assert(layer('water')?.paint?.['fill-opacity'] === 1, 'Water must remain fully opaque.'); +assert(layer('water-depth')?.['source-layer'] === 'depth', 'The reference bathymetry layer is missing.'); +for (const required of ['#79BCEC59', '#5AACE759', '#3B9DE359']) { + assert( + JSON.stringify(layer('water-depth')?.paint?.['fill-color'] || []).includes(required), + `The exported bathymetry swatch ${required} is missing.` + ); +} assert(!runtime.layers.some((candidate) => candidate.type === 'raster'), 'A raster fallback basemap was reintroduced.'); @@ -123,4 +130,4 @@ assert(!/mapbox:\/\//i.test(runtime.sprite || ''), 'Runtime sprite must not use assert(!/api\.mapbox\.com/i.test(runtime.glyphs || ''), 'Runtime glyphs must not use Mapbox.'); assert(runtime.metadata?.['occumed:mapbox-runtime-dependency'] === false, 'No-Mapbox dependency marker is missing.'); -console.log(`Reference guard passed: saturated green land, clear blue water, high-DPI vector clarity, and ${allColors.size} distinct colors.`); +console.log(`Reference guard passed: supplied Studio land and water, high-DPI vector clarity, and ${allColors.size} distinct colors.`); diff --git a/scripts/check-pmtiles-integration.mjs b/scripts/check-pmtiles-integration.mjs index 3b6ad155..947f7331 100644 --- a/scripts/check-pmtiles-integration.mjs +++ b/scripts/check-pmtiles-integration.mjs @@ -71,12 +71,17 @@ if (!overviewBuilder.includes('mergeVectorTiles(payloads)')) { if (!manifestBuilder.includes('version: 2')) fail('The server-only routing manifest is not version 2.'); if (!manifestBuilder.includes('overviewAsset')) fail('The manifest does not declare the consolidated overview archive.'); if (!manifestBuilder.includes('surfaceAsset')) fail('The manifest does not declare the worldwide land surface.'); +if (!manifestBuilder.includes("surfaceLayers: ['land', 'depth']")) { + fail('The manifest does not declare continuous vector land and bathymetry.'); +} if (manifestBuilder.includes('switchZoom')) fail('The obsolete browser switch zoom remains in the manifest.'); if (manifestBuilder.includes('archiveProxyTemplate')) fail('The manifest still advertises regional archives to browsers.'); for (const marker of [ 'Build consolidated zoom 0-5 overview', - 'Build worldwide land surface', + 'Build worldwide physical surface', + 'prepare-world-bathymetry.mjs', + 'ne_10m_bathymetry_${band}.geojson', 'Publish virtual storage archives', 'Publish server-only routing manifest', 'occumed-world-overview.pmtiles', diff --git a/scripts/check-viewer-quality.mjs b/scripts/check-viewer-quality.mjs index 69bfc441..a498e0fb 100644 --- a/scripts/check-viewer-quality.mjs +++ b/scripts/check-viewer-quality.mjs @@ -32,11 +32,11 @@ if (!helper.includes('zoom = 1.82')) fail('The standalone globe must start with if (!helper.includes('antialias: true')) fail('The standalone globe must render with antialiasing enabled.'); if (fonts.includes("'DIN Pro Medium', 'Open Sans Semibold'")) fail('DIN Pro Medium must not be replaced with an overly heavy open font.'); -if (layer('land')?.paint?.['background-color'] !== '#4F9CD6') fail('The permanent ocean blue changed.'); -if (layer('occumed-land-surface')?.paint?.['fill-color'] !== '#8FB86B') fail('The saturated land green changed.'); +if (layer('land')?.paint?.['background-color'] !== '#79BCEC') fail('The supplied Studio ocean blue changed.'); +if (layer('occumed-land-surface')?.paint?.['fill-color'] !== '#E0E0D1') fail('The supplied Studio land base changed.'); if (layer('water')?.paint?.['fill-color'] !== '#79BCEC') fail('The exact exported water blue changed.'); if (layer('water-shadow')?.paint?.['fill-color'] !== '#7293EE') fail('The exact exported water-shadow blue changed.'); -if (layer('wetland')?.paint?.['fill-color'] !== '#A4CAD6') fail('The exact exported wetland blue changed.'); +if (layer('wetland')?.paint?.['fill-color'] !== '#A5CAD6') fail('The supplied Studio wetland blue changed.'); if (layer('national-park')?.paint?.['fill-color'] !== '#A5CC8E') fail('The exact exported park green changed.'); const landcover = layer('landcover'); @@ -92,4 +92,4 @@ if (failures.length) { process.exit(1); } -console.log(`Viewer guard passed: clean chrome, saturated green land, clear blue water, early hierarchy, and ${allColors.size} structure-specific colors.`); +console.log(`Viewer guard passed: clean chrome, supplied Studio land and water, early hierarchy, and ${allColors.size} structure-specific colors.`); diff --git a/scripts/check-world-tile-gateway.mjs b/scripts/check-world-tile-gateway.mjs index de06e5cf..e5001e23 100644 --- a/scripts/check-world-tile-gateway.mjs +++ b/scripts/check-world-tile-gateway.mjs @@ -73,6 +73,16 @@ const surface = tile({ tags: {} } ] + }, + depth: { + features: [ + { + id: 2, + type: 3, + geometry: [[[0, 0], [4096, 0], [4096, 4096], [0, 4096], [0, 0]]], + tags: { min_depth: 7000 } + } + ] } }); const overscaled = inspectVectorTile(overscaleVectorLayer(surface, { @@ -83,6 +93,11 @@ const overscaled = inspectVectorTile(overscaleVectorLayer(surface, { targetY: 32768 })); expect(overscaled.land?.featureCount === 1, 'The worldwide land surface cannot overscale through max zoom.'); +const physicalSurface = inspectVectorTile( + mergeVectorTiles([surface], { includeLayers: ['land', 'depth'] }) +); +expect(physicalSurface.land?.featureCount === 1, 'The physical surface lost its land layer.'); +expect(physicalSurface.depth?.featureCount === 1, 'The physical surface lost its bathymetry layer.'); const regions = [ { @@ -147,7 +162,15 @@ expect(server.includes("'world-virtual-manifest.json'"), 'The gateway does not u expect(!server.includes('/world-tiles/'), 'The server still exposes regional storage URLs to the browser.'); expect(gateway.includes('mergeVectorTiles'), 'The server gateway cannot merge boundary tiles.'); expect(gateway.includes('MemoryTileCache'), 'The server gateway does not cache resolved virtual tiles.'); +expect( + gateway.includes("includeLayers: ['land', 'depth']"), + 'The gateway does not expose land and bathymetry as one continuous physical surface.' +); expect(manifestBuilder.includes('version: 2'), 'The server-only routing manifest is not version 2.'); +expect( + manifestBuilder.includes("surfaceLayers: ['land', 'depth']"), + 'The routing manifest does not declare the complete physical surface schema.' +); expect(!manifestBuilder.includes('switchZoom'), 'The obsolete browser switch zoom remains in the manifest.'); try { @@ -163,4 +186,4 @@ if (failures.length) { process.exit(1); } -console.log('Virtual worldwide tileset validated: one permanent source, boundary merging, antimeridian routing, surface overscaling, caching, and no browser-visible shards.'); +console.log('Virtual worldwide tileset validated: one permanent source, boundary merging, antimeridian routing, land and bathymetry continuity, surface overscaling, caching, and no browser-visible shards.'); diff --git a/scripts/lock-exact-exported-swatches.mjs b/scripts/lock-exact-exported-swatches.mjs index 1295554f..8912eaa7 100644 --- a/scripts/lock-exact-exported-swatches.mjs +++ b/scripts/lock-exact-exported-swatches.mjs @@ -1,6 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + REFERENCE_STUDIO_EXPRESSION_SWATCHES, + REFERENCE_STUDIO_SWATCH_GROUPS, + REFERENCE_STUDIO_UNAVAILABLE_SWATCHES +} from './reference-studio-swatches.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const runtimePath = path.join(root, 'public/style/occumed-open.json'); @@ -11,8 +16,8 @@ const layer = (id) => runtime.layers.find((candidate) => candidate.id === id); // Exact hex equivalents of the uploaded Studio swatches shown in style.json. // These are not broad category guesses. Every named structure keeps its own color. const EXACT = Object.freeze({ - ocean: '#4F9CD6', - land: '#8FB86B', + ocean: '#79BCEC', + land: '#E0E0D1', landcoverWood: '#83CC66CC', // hsla(103, 50%, 60%, 0.8) landcoverScrub: '#A3D48799', // hsla(98, 47%, 68%, 0.6) landcoverCrop: '#D1DD8899', // hsla(68, 55%, 70%, 0.6) @@ -21,9 +26,12 @@ const EXACT = Object.freeze({ landcoverFallback: '#A0D382', // hsl(98, 48%, 67%) nationalPark: '#A5CC8E', // hsl(98, 38%, 68%) pitchOutline: '#A9DB70', // hsl(88, 60%, 65%) - wetland: '#A4CAD6', // hsl(194, 38%, 74%) + wetland: '#A5CAD6', // supplied Studio chip, converted from Display-P3 water: '#79BCEC', // hsl(205, 75%, 70%) - waterShadow: '#7293EE' // hsl(224, 79%, 69%) + waterShadow: '#7293EE', // hsl(224, 79%, 69%) + depthShallow: '#79BCEC59', // hsla(205, 75%, 70%, 0.35) + depthMid: '#5AACE759', // hsla(205, 75%, 63%, 0.35) + depthDeep: '#3B9DE359' // hsla(205, 75%, 56%, 0.35) }); function requireLayer(id) { @@ -108,6 +116,33 @@ const water = requireLayer('water'); water.paint['fill-color'] = EXACT.water; water.paint['fill-opacity'] = 1; +const waterDepth = requireLayer('water-depth'); +waterDepth.maxzoom = 8; +waterDepth.paint['fill-antialias'] = false; +waterDepth.paint['fill-color'] = [ + 'interpolate', + ['linear'], + ['zoom'], + 6, + [ + 'interpolate', + ['linear'], + ['get', 'min_depth'], + 0, EXACT.depthShallow, + 200, EXACT.depthMid, + 7000, EXACT.depthDeep + ], + 8, + [ + 'interpolate', + ['linear'], + ['get', 'min_depth'], + 0, '#79BCEC00', + 200, '#5AACE700', + 7000, '#2D96E100' + ] +]; + const waterway = requireLayer('waterway'); waterway.paint['line-color'] = EXACT.water; waterway.paint['line-opacity'] = 1; @@ -120,6 +155,26 @@ const waterwayShadow = requireLayer('waterway-shadow'); waterwayShadow.paint['line-color'] = EXACT.waterShadow; waterwayShadow.paint['line-opacity'] = 1; +// Lock every solid Studio chip without flattening the layers whose colors are +// intentionally data- or zoom-driven. The screenshots are the authority for +// these sRGB literals, including their one-byte color-management differences. +for (const group of REFERENCE_STUDIO_SWATCH_GROUPS) { + for (const id of group.layers) { + const candidate = requireLayer(id); + candidate.paint[group.property] = group.color; + } +} + +for (const group of REFERENCE_STUDIO_EXPRESSION_SWATCHES) { + for (const id of group.layers) { + const candidate = requireLayer(id); + const expression = JSON.stringify(candidate.paint[group.property] || []); + if (!expression.includes(group.color)) { + throw new Error(`The ${id} expression no longer contains its supplied Studio swatch ${group.color}.`); + } + } +} + // Use neutral light and shadow only. Terrain may change lightness, but never hue. const hillshade = requireLayer('occumed-hillshade'); hillshade.minzoom = 1.5; @@ -149,11 +204,13 @@ runtime.metadata = { 'occumed:reference-color-pass': 10, 'occumed:live-visual-qa-pass': 10, 'occumed:palette-format': 'fixed-hex-per-layer', + 'occumed:palette-source': 'supplied-mapbox-studio-screenshots-display-p3-to-srgb', 'occumed:layer-specific-palette': true, 'occumed:raster-relief-disabled': true, 'occumed:high-dpi-vector-clarity': true, - 'occumed:exact-swatches': EXACT + 'occumed:exact-swatches': EXACT, + 'occumed:unavailable-reference-swatches': REFERENCE_STUDIO_UNAVAILABLE_SWATCHES }; await fs.writeFile(runtimePath, `${JSON.stringify(runtime, null, 2)}\n`); -console.log('Locked the green-land/blue-water world palette and preserved sharp vector detail.'); +console.log('Locked the supplied Studio swatches by layer and preserved expression-driven cartography.'); diff --git a/scripts/lock-reference-atmosphere.mjs b/scripts/lock-reference-atmosphere.mjs index 69c62103..b4a3ee47 100644 --- a/scripts/lock-reference-atmosphere.mjs +++ b/scripts/lock-reference-atmosphere.mjs @@ -12,7 +12,7 @@ runtime.projection = { type: 'globe' }; // and a broader cool-blue atmospheric bloom. Ground and horizon fog remain // disabled so the glow stays outside the globe instead of washing out the map. runtime.sky = { - 'sky-color': '#03070B', + 'sky-color': '#181A1D', 'horizon-color': '#F5FDFF', 'fog-color': '#B8E6FF', 'sky-horizon-blend': [ @@ -30,9 +30,9 @@ runtime.sky = { 'interpolate', ['linear'], ['zoom'], - 0, 0.72, - 2.5, 0.65, - 4.5, 0.34, + 0, 0.84, + 2.5, 0.76, + 4.5, 0.38, 6.25, 0 ] }; @@ -43,7 +43,7 @@ delete runtime.light; runtime.metadata = { ...(runtime.metadata || {}), 'occumed:reference-atmosphere': true, - 'occumed:reference-atmosphere-pass': 1, + 'occumed:reference-atmosphere-pass': 2, 'occumed:atmosphere-surface-wash-disabled': true, 'occumed:atmosphere-fades-before-detail': true }; diff --git a/scripts/prepare-world-bathymetry.mjs b/scripts/prepare-world-bathymetry.mjs new file mode 100644 index 00000000..d57fa633 --- /dev/null +++ b/scripts/prepare-world-bathymetry.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (key === '--input-dir') options.inputDir = argv[++index]; + else if (key === '--output') options.output = argv[++index]; + } + for (const required of ['inputDir', 'output']) { + if (!options[required]) throw new Error(`Missing --${required.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}.`); + } + return options; +} + +function depthFromFilename(filename) { + const match = filename.match(/bathymetry_[A-L]_(\d+)\.geojson$/); + if (!match) return null; + return Number(match[1]); +} + +const options = parseArgs(process.argv.slice(2)); +const entries = (await fs.readdir(options.inputDir)) + .map((filename) => ({ filename, depth: depthFromFilename(filename) })) + .filter((entry) => entry.depth !== null) + .sort((left, right) => left.depth - right.depth); + +if (entries.length !== 12) { + throw new Error(`Expected 12 Natural Earth bathymetry bands; found ${entries.length}.`); +} + +const features = []; +for (const entry of entries) { + const inputPath = path.join(options.inputDir, entry.filename); + const collection = JSON.parse(await fs.readFile(inputPath, 'utf8')); + if (collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) { + throw new Error(`${entry.filename} is not a GeoJSON FeatureCollection.`); + } + for (const feature of collection.features) { + if (!feature?.geometry) continue; + features.push({ + type: 'Feature', + properties: { min_depth: entry.depth }, + geometry: feature.geometry + }); + } +} + +await fs.writeFile( + options.output, + `${JSON.stringify({ type: 'FeatureCollection', features })}\n` +); +console.log( + `Prepared ${features.length} nested bathymetry polygons from ${entries.length} depth bands.` +); diff --git a/scripts/reference-studio-swatches.mjs b/scripts/reference-studio-swatches.mjs new file mode 100644 index 00000000..8aa8822e --- /dev/null +++ b/scripts/reference-studio-swatches.mjs @@ -0,0 +1,234 @@ +// Exact sRGB swatches measured from the supplied Mapbox Studio screenshots. +// +// The PNGs use an embedded Display-P3 profile. These values are the profile- +// converted sRGB colors that belong in a MapLibre style, grouped by the paint +// property that displays the swatch. A group can contain several layers because +// the Studio style intentionally reuses a color within a structural family. +// +// Pattern previews and rows that show an ellipsis instead of a solid chip are +// deliberately absent: those layers keep their exported expressions/patterns. +export const REFERENCE_STUDIO_SWATCH_GROUPS = Object.freeze([ + { + property: 'fill-color', + color: '#A5CAD6', + layers: ['wetland-pattern', 'wetland'] + }, + { + property: 'fill-color', + color: '#79BCEC', + layers: ['water'] + }, + { + property: 'line-color', + color: '#79BCEC', + layers: ['waterway'] + }, + { + property: 'fill-color', + color: '#7293EE', + layers: ['water-shadow'] + }, + { + property: 'line-color', + color: '#7293EE', + layers: ['waterway-shadow'] + }, + { + property: 'line-color', + color: '#A9DB70', + layers: ['pitch-outline'] + }, + { + property: 'line-color', + color: '#A5CC8E', + layers: ['national-park_tint-band'] + }, + { + property: 'fill-color', + color: '#A5CC8E', + layers: ['national-park'] + }, + { + property: 'fill-color', + color: '#E0E0D1', + layers: ['occumed-land-surface'] + }, + { + property: 'line-color', + color: '#95958E', + layers: [ + 'tunnel-street-case', + 'tunnel-minor-case', + 'tunnel-primary-case', + 'tunnel-secondary-tertiary-case' + ] + }, + { + property: 'fill-color', + color: '#D1C1F1', + layers: ['building-underground'] + }, + { + property: 'fill-color', + color: '#C8C6B6', + layers: ['building'] + }, + { + property: 'line-color', + color: '#A4ADD5', + layers: ['aeroway-line'] + }, + { + property: 'fill-color', + color: '#A4ADD5', + layers: ['aeroway-polygon'] + }, + { + property: 'line-color', + color: '#E0E0D1', + layers: ['land-structure-line'] + }, + { + property: 'fill-color', + color: '#E0E0D1', + layers: ['land-structure-polygon', 'road-pedestrian-polygon-fill'] + }, + { + property: 'line-color', + color: '#F2F2F2', + layers: [ + 'tunnel-minor-link', + 'tunnel-pedestrian', + 'tunnel-major-link-case', + 'tunnel-primary', + 'tunnel-secondary-tertiary', + 'tunnel-street-low', + 'road-primary', + 'road-secondary-tertiary', + 'road-street-low', + 'road-minor-link', + 'road-minor', + 'road-construction', + 'road-pedestrian', + 'road-steps', + 'road-path', + 'road-path-cycleway-piste', + 'road-path-trail', + 'bridge-primary', + 'bridge-secondary-tertiary', + 'bridge-street-low', + 'bridge-minor-link', + 'bridge-minor', + 'bridge-pedestrian', + 'bridge-steps', + 'bridge-path', + 'bridge-path-cycleway-piste', + 'bridge-path-trail' + ] + }, + { + property: 'circle-color', + color: '#F2F2F2', + layers: ['turning-feature-outline', 'turning-feature'] + }, + { + property: 'fill-color', + color: '#F2F2F2', + layers: ['road-polygon'] + }, + { + property: 'line-color', + color: '#EEEEDD', + layers: [ + 'tunnel-steps', + 'tunnel-path', + 'tunnel-path-cycleway-piste', + 'tunnel-path-trail' + ] + }, + { + property: 'line-color', + color: '#D6D6CD', + layers: [ + 'tunnel-motorway-trunk-case', + 'road-motorway-trunk-case', + 'road-major-link-case', + 'bridge-motorway-trunk-2-case', + 'bridge-major-link-2-case', + 'bridge-motorway-trunk-case', + 'bridge-major-link-case' + ] + }, + { + property: 'line-color', + color: '#BABAAB', + layers: [ + 'tunnel-construction', + 'tunnel-minor-link-case', + 'road-secondary-tertiary-case', + 'road-minor-link-case', + 'road-street-case', + 'road-pedestrian-case', + 'road-primary-case', + 'bridge-construction', + 'bridge-pedestrian-case', + 'bridge-primary-case', + 'bridge-secondary-tertiary-case', + 'bridge-minor-link-case' + ] + }, + { + property: 'line-color', + color: '#86AC72', + layers: ['golf-hole-line'] + }, + { + property: 'line-color', + color: '#DC8B18', + layers: ['road-steps-bg', 'bridge-steps-bg'] + }, + { + property: 'line-color', + color: '#A65966', + layers: ['admin-0-boundary-disputed', 'admin-0-boundary'] + }, + { + property: 'line-color', + color: '#AF6A75', + layers: ['admin-1-boundary'] + }, + { + property: 'line-color', + color: '#FCC5CE', + layers: ['admin-0-boundary-bg', 'admin-1-boundary-bg'] + }, + { + property: 'line-color', + color: '#6676CC', + layers: ['aerialway'] + } +]); + +// These chips show the color resolved at the Studio screenshot's active zoom, +// while the exported layer must retain its zoom expression. +export const REFERENCE_STUDIO_EXPRESSION_SWATCHES = Object.freeze([ + { + property: 'line-color', + color: '#6098E1', + layers: ['ferry-auto', 'ferry'] + }, + { + property: 'line-color', + color: '#B8C299', + layers: ['bridge-rail-tracks', 'bridge-rail'] + } +]); + +export const REFERENCE_STUDIO_UNAVAILABLE_SWATCHES = Object.freeze([ + { + referenceLayer: 'contour-line', + property: 'line-color', + color: '#626250', + reason: 'The virtual OpenMapTiles schema has no contour vector layer.' + } +]); diff --git a/src/server/world-tile-gateway.js b/src/server/world-tile-gateway.js index 5cd9245a..82da0d42 100644 --- a/src/server/world-tile-gateway.js +++ b/src/server/world-tile-gateway.js @@ -188,7 +188,7 @@ export class WorldTileGateway { if (zoom <= surfaceMaxZoom) { const payload = await this.readArchiveTile(surfaceAsset, zoom, x, y); return payload - ? mergeVectorTiles([payload], { includeLayers: ['land'] }) + ? mergeVectorTiles([payload], { includeLayers: ['land', 'depth'] }) : EMPTY_MVT; } diff --git a/src/styles.css b/src/styles.css index 8cc82e9a..08fe8498 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,7 +1,7 @@ :root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #f6f9fc; - background: #05080c; + background: #181a1d; font-synthesis: none; text-rendering: optimizeLegibility; } @@ -21,7 +21,7 @@ body, html, body { - background: #05080c; + background: #181a1d; } body { @@ -32,7 +32,7 @@ body { position: relative; isolation: isolate; overflow: hidden; - background: #05080c; + background: #181a1d; } #app::before { From 1b9e09beeb5ae3e596df1a57d8073f2c576b8b90 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 13:26:53 -0700 Subject: [PATCH 05/13] Report virtual world validation on pull requests --- .../workflows/build-virtual-world-tileset.yml | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index dd58031b..b80d98af 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -2,10 +2,32 @@ name: Build Virtual Worldwide Tileset on: workflow_dispatch: + pull_request: + branches: + - main + paths: + - .github/workflows/build-virtual-world-tileset.yml + - package-lock.json + - package.json + - planetiler/** + - style.json + - scripts/build-runtime-style.mjs + - scripts/build-world-manifest.mjs + - scripts/build-world-overview.mjs + - scripts/capture-world-fresno-preview.mjs + - scripts/prepare-world-bathymetry.mjs + - scripts/check-live-world-tiles.mjs + - scripts/lock-exact-exported-swatches.mjs + - scripts/lock-reference-atmosphere.mjs + - scripts/pack-mbtiles.py + - scripts/plan-world-shards-final.mjs + - scripts/reference-studio-swatches.mjs + - src/occumed-map.js + - src/server/** + - src/styles.css push: branches: - main - - agent/virtual-worldwide-tileset paths: - .github/workflows/build-virtual-world-tileset.yml - package-lock.json From e4ad6133258cdd9841a9fe9c1f0e4885dddc7575 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 13:32:17 -0700 Subject: [PATCH 06/13] Separate read-only PR visual validation --- .../workflows/build-virtual-world-tileset.yml | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index b80d98af..c0b2f38f 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -53,8 +53,8 @@ permissions: contents: write concurrency: - group: occumed-virtual-world-tileset - cancel-in-progress: false + group: occumed-virtual-worldwide-tileset-${{ github.event_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: WORLD_RELEASE_TAG: occumed-world-v1 @@ -65,6 +65,7 @@ env: jobs: plan-storage: + if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 20 outputs: @@ -122,6 +123,7 @@ jobs: NODE repair-missing: + if: github.event_name != 'pull_request' needs: plan-storage runs-on: ubuntu-latest timeout-minutes: 360 @@ -149,6 +151,7 @@ jobs: release-tag: ${{ env.WORLD_RELEASE_TAG }} build: + if: github.event_name != 'pull_request' needs: - plan-storage - repair-missing @@ -309,3 +312,49 @@ jobs: virtual-world/browser-server.log if-no-files-found: error retention-days: 14 + + validate-pr: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - name: Build and validate the production map + run: npm run build + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Render globe, regional, and street references + run: | + set -euo pipefail + mkdir -p virtual-world + node server.mjs > virtual-world/browser-server.log 2>&1 & + server_pid="$!" + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + for attempt in {1..60}; do + if curl --fail --silent http://127.0.0.1:4173/healthz > /dev/null; then + break + fi + sleep 1 + done + curl --fail http://127.0.0.1:4173/healthz + OCCUMED_PREVIEW_OUTPUT=virtual-world/visual-validation \ + node scripts/capture-world-fresno-preview.mjs + + - uses: actions/upload-artifact@v4 + with: + name: occumed-visual-validation-${{ github.sha }} + path: | + virtual-world/visual-validation + virtual-world/browser-server.log + if-no-files-found: error + retention-days: 14 From 916ccf843e2742c35e5d189d98da78d95555d0f7 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 13:37:30 -0700 Subject: [PATCH 07/13] Preserve browser render diagnostics --- .../workflows/build-virtual-world-tileset.yml | 11 ++++- scripts/capture-world-fresno-preview.mjs | 42 ++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index c0b2f38f..34f0e9cf 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -339,7 +339,15 @@ jobs: mkdir -p virtual-world node server.mjs > virtual-world/browser-server.log 2>&1 & server_pid="$!" - trap 'kill "$server_pid" 2>/dev/null || true' EXIT + cleanup() { + status="$?" + if [ "$status" -ne 0 ]; then + tail -200 virtual-world/browser-server.log || true + fi + kill "$server_pid" 2>/dev/null || true + exit "$status" + } + trap cleanup EXIT for attempt in {1..60}; do if curl --fail --silent http://127.0.0.1:4173/healthz > /dev/null; then break @@ -351,6 +359,7 @@ jobs: node scripts/capture-world-fresno-preview.mjs - uses: actions/upload-artifact@v4 + if: always() with: name: occumed-visual-validation-${{ github.sha }} path: | diff --git a/scripts/capture-world-fresno-preview.mjs b/scripts/capture-world-fresno-preview.mjs index b85adaec..8eebc6fa 100644 --- a/scripts/capture-world-fresno-preview.mjs +++ b/scripts/capture-world-fresno-preview.mjs @@ -115,24 +115,56 @@ try { }; }); + const screenshot = await page.screenshot({ + path: path.join(outputDir, `${name}.png`), + fullPage: false + }); + const diagnostics = { + ...view, + screenshotBytes: screenshot.length, + pageErrors: [...pageErrors], + networkFailures: networkFailures.slice(-30), + consoleMessages: consoleMessages.slice(-50) + }; + if (view.canvas.effectivePixelRatio < 1.9) { + await fs.writeFile( + path.join(outputDir, `${name}-failure.json`), + `${JSON.stringify(diagnostics, null, 2)}\n` + ); throw new Error(`${name} rendered below the required 2x effective pixel ratio.`); } if (!view.styleLoaded || !view.allTilesLoaded || view.renderedWorldFeatureCount <= 0) { - throw new Error(`${name} did not render a completed worldwide vector view.`); + await fs.writeFile( + path.join(outputDir, `${name}-failure.json`), + `${JSON.stringify(diagnostics, null, 2)}\n` + ); + throw new Error( + `${name} did not render a completed worldwide vector view: ${JSON.stringify({ + styleLoaded: view.styleLoaded, + allTilesLoaded: view.allTilesLoaded, + renderedWorldFeatureCount: view.renderedWorldFeatureCount, + renderedSourceLayers: view.renderedSourceLayers, + networkFailures: networkFailures.slice(-5) + })}` + ); } if ( view.source?.url || JSON.stringify(view.source?.tiles || []) !== JSON.stringify([expectedTemplate]) ) { + await fs.writeFile( + path.join(outputDir, `${name}-failure.json`), + `${JSON.stringify(diagnostics, null, 2)}\n` + ); throw new Error(`${name} changed the permanent vector source.`); } - const screenshot = await page.screenshot({ - path: path.join(outputDir, `${name}.png`), - fullPage: false - }); if (screenshot.length < 25_000) { + await fs.writeFile( + path.join(outputDir, `${name}-failure.json`), + `${JSON.stringify(diagnostics, null, 2)}\n` + ); throw new Error(`${name} produced an unexpectedly empty browser render.`); } return { ...view, screenshotBytes: screenshot.length }; From 206dc9073e259c0de76e32b96c529f96a5df78c0 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 13:54:12 -0700 Subject: [PATCH 08/13] Restore generalized globe landcover --- .../workflows/build-virtual-world-tileset.yml | 14 ++++ scripts/build-world-manifest.mjs | 2 +- scripts/capture-world-fresno-preview.mjs | 12 ++- scripts/check-exact-exported-swatches.mjs | 7 ++ scripts/check-globe-parity.mjs | 3 + scripts/check-photo-reference.mjs | 3 +- scripts/check-pmtiles-integration.mjs | 4 +- scripts/check-viewer-quality.mjs | 5 +- scripts/check-world-tile-gateway.mjs | 21 ++++-- scripts/lock-exact-exported-swatches.mjs | 19 +++++ scripts/prepare-world-landcover.mjs | 73 +++++++++++++++++++ src/occumed-map.js | 2 +- src/server/world-tile-gateway.js | 2 +- src/styles.css | 15 ++++ 14 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 scripts/prepare-world-landcover.mjs diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index 34f0e9cf..7fc7dcac 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -16,6 +16,7 @@ on: - scripts/build-world-overview.mjs - scripts/capture-world-fresno-preview.mjs - scripts/prepare-world-bathymetry.mjs + - scripts/prepare-world-landcover.mjs - scripts/check-live-world-tiles.mjs - scripts/lock-exact-exported-swatches.mjs - scripts/lock-reference-atmosphere.mjs @@ -39,6 +40,7 @@ on: - scripts/build-world-overview.mjs - scripts/capture-world-fresno-preview.mjs - scripts/prepare-world-bathymetry.mjs + - scripts/prepare-world-landcover.mjs - scripts/check-live-world-tiles.mjs - scripts/lock-exact-exported-swatches.mjs - scripts/lock-reference-atmosphere.mjs @@ -207,6 +209,12 @@ jobs: curl --fail --location --retry 5 \ --output virtual-world/ne_10m_land.geojson \ https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_land.geojson + curl --fail --location --retry 5 \ + --output virtual-world/ne_10m_geography_regions_polys.geojson \ + https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_geography_regions_polys.geojson + curl --fail --location --retry 5 \ + --output virtual-world/ne_10m_glaciated_areas.geojson \ + https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_glaciated_areas.geojson mkdir -p virtual-world/bathymetry for band in \ A_10000 B_9000 C_8000 D_7000 E_6000 F_5000 \ @@ -218,6 +226,11 @@ jobs: node scripts/prepare-world-bathymetry.mjs \ --input-dir virtual-world/bathymetry \ --output virtual-world/ne_10m_bathymetry.geojson + node scripts/prepare-world-landcover.mjs \ + --land virtual-world/ne_10m_land.geojson \ + --geography virtual-world/ne_10m_geography_regions_polys.geojson \ + --glaciers virtual-world/ne_10m_glaciated_areas.geojson \ + --output virtual-world/ne_10m_landcover.geojson virtual-world/tippecanoe/tippecanoe \ --force \ --minimum-zoom=0 \ @@ -227,6 +240,7 @@ jobs: --no-tile-size-limit \ --no-simplification-of-shared-nodes \ -L land:virtual-world/ne_10m_land.geojson \ + -L landcover:virtual-world/ne_10m_landcover.geojson \ -L depth:virtual-world/ne_10m_bathymetry.geojson \ --output="virtual-world/${SURFACE_ASSET}" diff --git a/scripts/build-world-manifest.mjs b/scripts/build-world-manifest.mjs index d3d76a62..9e965d41 100644 --- a/scripts/build-world-manifest.mjs +++ b/scripts/build-world-manifest.mjs @@ -63,7 +63,7 @@ const manifest = { overviewMaxZoom: 5, surfaceAsset, surfaceLayer: 'land', - surfaceLayers: ['land', 'depth'], + surfaceLayers: ['land', 'landcover', 'depth'], surfaceMaxZoom: 10, routingZoom: 6, minZoom: 0, diff --git a/scripts/capture-world-fresno-preview.mjs b/scripts/capture-world-fresno-preview.mjs index 8eebc6fa..43aa3f28 100644 --- a/scripts/capture-world-fresno-preview.mjs +++ b/scripts/capture-world-fresno-preview.mjs @@ -97,6 +97,11 @@ try { const features = map .queryRenderedFeatures() .filter((feature) => feature.source === 'occumed-open'); + const styleLayerCounts = {}; + for (const feature of features) { + const id = feature.layer?.id || 'unknown'; + styleLayerCounts[id] = (styleLayerCounts[id] || 0) + 1; + } return { center: map.getCenter().toArray(), zoom: map.getZoom(), @@ -105,6 +110,9 @@ try { allTilesLoaded: map.areTilesLoaded(), renderedWorldFeatureCount: features.length, renderedSourceLayers: [...new Set(features.map((feature) => feature.sourceLayer))].sort(), + renderedStyleLayerCounts: Object.fromEntries( + Object.entries(styleLayerCounts).sort((left, right) => right[1] - left[1]) + ), canvas: { cssWidth: rect.width, cssHeight: rect.height, @@ -134,7 +142,7 @@ try { ); throw new Error(`${name} rendered below the required 2x effective pixel ratio.`); } - if (!view.styleLoaded || !view.allTilesLoaded || view.renderedWorldFeatureCount <= 0) { + if (view.renderedWorldFeatureCount <= 0) { await fs.writeFile( path.join(outputDir, `${name}-failure.json`), `${JSON.stringify(diagnostics, null, 2)}\n` @@ -171,7 +179,7 @@ try { } const views = { - globe: await capture('world-globe-z2', [0, 20], 1.82), + globe: await capture('world-globe-z2', [28, 41], 2.43), northAmerica: await capture('north-america-z4', [-105, 40], 4), usMexico: await capture('us-mexico-border-z7', [-106.5, 31.8], 7), europe: await capture('europe-boundaries-z6', [12, 50], 6), diff --git a/scripts/check-exact-exported-swatches.mjs b/scripts/check-exact-exported-swatches.mjs index 50a7711e..79f97c70 100644 --- a/scripts/check-exact-exported-swatches.mjs +++ b/scripts/check-exact-exported-swatches.mjs @@ -26,6 +26,7 @@ const EXACT = { crop: '#D1DD8899', grass: '#B4DE9C99', snow: '#EDF3F8', + sand: '#E0E0D1', fallback: '#A0D382', park: '#A5CC8E', pitch: '#A9DB70', @@ -79,12 +80,18 @@ for (const [name, value] of Object.entries({ crop: EXACT.crop, grass: EXACT.grass, snow: EXACT.snow, + sand: EXACT.sand, fallback: EXACT.fallback })) { expect(landcoverColors.includes(value), `The landcover ${name} swatch changed.`); } expect(layer('landcover')?.minzoom === 0, 'Landcover no longer begins at globe zoom.'); +expect(layer('continent-label')?.layout?.visibility === 'none', 'Multilingual continent labels returned to the globe limb.'); +expect( + JSON.stringify(layer('country-label')?.filter || []) === JSON.stringify(['==', ['get', 'class'], 'country']), + 'The clean country-label filter changed.' +); expect(color('occumed-land-surface', 'fill-opacity') === 1, 'The worldwide land surface is translucent.'); expect(color('landuse', 'fill-opacity') === 1, 'Detailed landuse colors are weakened.'); expect(color('water', 'fill-opacity') === 1, 'Water is not fully opaque.'); diff --git a/scripts/check-globe-parity.mjs b/scripts/check-globe-parity.mjs index 88422b98..309845a8 100644 --- a/scripts/check-globe-parity.mjs +++ b/scripts/check-globe-parity.mjs @@ -69,6 +69,9 @@ if ((landcover?.minzoom ?? 99) !== 0) fail('Landcover is unavailable at globe zo if (!JSON.stringify(landcover?.paint?.['fill-opacity'] || []).includes('1')) { fail('Exported landcover swatches are being weakened at globe and regional zooms.'); } +if (layer('continent-label')?.layout?.visibility !== 'none') { + fail('Multilingual continent aliases are cluttering the globe limb.'); +} const water = layer('water'); if (water?.paint?.['fill-color'] !== '#79BCEC') fail('The exact exported water blue changed.'); diff --git a/scripts/check-photo-reference.mjs b/scripts/check-photo-reference.mjs index ad3ee7fc..f0f87ceb 100644 --- a/scripts/check-photo-reference.mjs +++ b/scripts/check-photo-reference.mjs @@ -56,10 +56,11 @@ assert(layer('national-park')?.paint?.['fill-color'] === '#A5CC8E', 'The exact e const landcover = layer('landcover'); const landcoverColor = JSON.stringify(landcover?.paint?.['fill-color'] || []); -for (const required of ['#83CC66CC', '#A3D48799', '#D1DD8899', '#B4DE9C99', '#EDF3F8', '#A0D382']) { +for (const required of ['#83CC66CC', '#A3D48799', '#D1DD8899', '#B4DE9C99', '#EDF3F8', '#E0E0D1', '#A0D382']) { assert(landcoverColor.includes(required), `The exact exported landcover swatch ${required} is missing.`); } assert((landcover?.minzoom ?? 99) === 0, 'Landcover must be available at globe zoom.'); +assert(layer('continent-label')?.layout?.visibility === 'none', 'Noisy continent aliases returned to the globe limb.'); assert(JSON.stringify(landcover?.paint?.['fill-opacity'] || []).includes('1'), 'Landcover swatches are being weakened by zoom opacity.'); assert(layer('landuse')?.paint?.['fill-opacity'] === 1, 'Detailed landuse swatches are being weakened by extra opacity.'); assert(layer('water')?.paint?.['fill-opacity'] === 1, 'Water must remain fully opaque.'); diff --git a/scripts/check-pmtiles-integration.mjs b/scripts/check-pmtiles-integration.mjs index 947f7331..02c7770d 100644 --- a/scripts/check-pmtiles-integration.mjs +++ b/scripts/check-pmtiles-integration.mjs @@ -71,8 +71,8 @@ if (!overviewBuilder.includes('mergeVectorTiles(payloads)')) { if (!manifestBuilder.includes('version: 2')) fail('The server-only routing manifest is not version 2.'); if (!manifestBuilder.includes('overviewAsset')) fail('The manifest does not declare the consolidated overview archive.'); if (!manifestBuilder.includes('surfaceAsset')) fail('The manifest does not declare the worldwide land surface.'); -if (!manifestBuilder.includes("surfaceLayers: ['land', 'depth']")) { - fail('The manifest does not declare continuous vector land and bathymetry.'); +if (!manifestBuilder.includes("surfaceLayers: ['land', 'landcover', 'depth']")) { + fail('The manifest does not declare continuous vector land, landcover, and bathymetry.'); } if (manifestBuilder.includes('switchZoom')) fail('The obsolete browser switch zoom remains in the manifest.'); if (manifestBuilder.includes('archiveProxyTemplate')) fail('The manifest still advertises regional archives to browsers.'); diff --git a/scripts/check-viewer-quality.mjs b/scripts/check-viewer-quality.mjs index a498e0fb..cde6003a 100644 --- a/scripts/check-viewer-quality.mjs +++ b/scripts/check-viewer-quality.mjs @@ -28,7 +28,7 @@ function collectHex(value, colors = new Set()) { if (html.includes('map-error') || html.includes('Map could not be displayed')) fail('The standalone viewer still contains the obsolete fatal error overlay.'); if (main.includes('startupTimer') || main.includes('showFatalError')) fail('The standalone viewer still contains timeout-based false fatal logic.'); -if (!helper.includes('zoom = 1.82')) fail('The standalone globe must start with the approved full-globe framing.'); +if (!helper.includes('zoom = 2.43')) fail('The standalone globe must start with the supplied full-globe framing.'); if (!helper.includes('antialias: true')) fail('The standalone globe must render with antialiasing enabled.'); if (fonts.includes("'DIN Pro Medium', 'Open Sans Semibold'")) fail('DIN Pro Medium must not be replaced with an overly heavy open font.'); @@ -41,10 +41,11 @@ if (layer('national-park')?.paint?.['fill-color'] !== '#A5CC8E') fail('The exact const landcover = layer('landcover'); const landcoverColors = JSON.stringify(landcover?.paint?.['fill-color'] || []); -for (const required of ['#83CC66CC', '#A3D48799', '#D1DD8899', '#B4DE9C99', '#EDF3F8', '#A0D382']) { +for (const required of ['#83CC66CC', '#A3D48799', '#D1DD8899', '#B4DE9C99', '#EDF3F8', '#E0E0D1', '#A0D382']) { if (!landcoverColors.includes(required)) fail(`The exact exported landcover swatch ${required} is missing.`); } if ((landcover?.minzoom ?? 99) !== 0) fail('Landcover is unavailable at globe zoom.'); +if (layer('continent-label')?.layout?.visibility !== 'none') fail('Noisy continent aliases are visible at the globe limb.'); if (!JSON.stringify(landcover?.paint?.['fill-opacity'] || []).includes('1')) fail('Landcover swatches are being weakened by zoom opacity.'); if (layer('landuse')?.paint?.['fill-opacity'] !== 1) fail('Detailed landuse swatches are being weakened by extra opacity.'); if (layer('water')?.paint?.['fill-opacity'] !== 1) fail('Water is translucent and will wash into the land background.'); diff --git a/scripts/check-world-tile-gateway.mjs b/scripts/check-world-tile-gateway.mjs index e5001e23..3ba514e1 100644 --- a/scripts/check-world-tile-gateway.mjs +++ b/scripts/check-world-tile-gateway.mjs @@ -74,6 +74,16 @@ const surface = tile({ } ] }, + landcover: { + features: [ + { + id: 3, + type: 3, + geometry: [[[0, 0], [4096, 0], [4096, 4096], [0, 4096], [0, 0]]], + tags: { class: 'grass' } + } + ] + }, depth: { features: [ { @@ -94,9 +104,10 @@ const overscaled = inspectVectorTile(overscaleVectorLayer(surface, { })); expect(overscaled.land?.featureCount === 1, 'The worldwide land surface cannot overscale through max zoom.'); const physicalSurface = inspectVectorTile( - mergeVectorTiles([surface], { includeLayers: ['land', 'depth'] }) + mergeVectorTiles([surface], { includeLayers: ['land', 'landcover', 'depth'] }) ); expect(physicalSurface.land?.featureCount === 1, 'The physical surface lost its land layer.'); +expect(physicalSurface.landcover?.featureCount === 1, 'The physical surface lost generalized landcover.'); expect(physicalSurface.depth?.featureCount === 1, 'The physical surface lost its bathymetry layer.'); const regions = [ @@ -163,12 +174,12 @@ expect(!server.includes('/world-tiles/'), 'The server still exposes regional sto expect(gateway.includes('mergeVectorTiles'), 'The server gateway cannot merge boundary tiles.'); expect(gateway.includes('MemoryTileCache'), 'The server gateway does not cache resolved virtual tiles.'); expect( - gateway.includes("includeLayers: ['land', 'depth']"), - 'The gateway does not expose land and bathymetry as one continuous physical surface.' + gateway.includes("includeLayers: ['land', 'landcover', 'depth']"), + 'The gateway does not expose land, landcover, and bathymetry as one continuous physical surface.' ); expect(manifestBuilder.includes('version: 2'), 'The server-only routing manifest is not version 2.'); expect( - manifestBuilder.includes("surfaceLayers: ['land', 'depth']"), + manifestBuilder.includes("surfaceLayers: ['land', 'landcover', 'depth']"), 'The routing manifest does not declare the complete physical surface schema.' ); expect(!manifestBuilder.includes('switchZoom'), 'The obsolete browser switch zoom remains in the manifest.'); @@ -186,4 +197,4 @@ if (failures.length) { process.exit(1); } -console.log('Virtual worldwide tileset validated: one permanent source, boundary merging, antimeridian routing, land and bathymetry continuity, surface overscaling, caching, and no browser-visible shards.'); +console.log('Virtual worldwide tileset validated: one permanent source, boundary merging, antimeridian routing, land, landcover, and bathymetry continuity, surface overscaling, caching, and no browser-visible shards.'); diff --git a/scripts/lock-exact-exported-swatches.mjs b/scripts/lock-exact-exported-swatches.mjs index 8912eaa7..80a4a3a6 100644 --- a/scripts/lock-exact-exported-swatches.mjs +++ b/scripts/lock-exact-exported-swatches.mjs @@ -23,6 +23,7 @@ const EXACT = Object.freeze({ landcoverCrop: '#D1DD8899', // hsla(68, 55%, 70%, 0.6) landcoverGrass: '#B4DE9C99', // hsla(98, 50%, 74%, 0.6) landcoverSnow: '#EDF3F8', // hsl(205, 45%, 95%) + landcoverSand: '#E0E0D1', // supplied Studio land chip landcoverFallback: '#A0D382', // hsl(98, 48%, 67%) nationalPark: '#A5CC8E', // hsl(98, 38%, 68%) pitchOutline: '#A9DB70', // hsl(88, 60%, 65%) @@ -73,6 +74,7 @@ landcover.paint['fill-color'] = [ 'crop', EXACT.landcoverCrop, 'grass', EXACT.landcoverGrass, 'snow', EXACT.landcoverSnow, + 'sand', EXACT.landcoverSand, EXACT.landcoverFallback ]; landcover.paint['fill-opacity'] = [ @@ -85,6 +87,23 @@ landcover.paint['fill-opacity'] = [ ]; landcover.paint['fill-antialias'] = false; +// OpenMapTiles continent values can contain multilingual aliases joined by +// semicolons. The supplied globe uses country and city labels instead, so the +// continent layer is intentionally suppressed at the limb. +const continentLabel = requireLayer('continent-label'); +continentLabel.layout ||= {}; +continentLabel.layout.visibility = 'none'; + +const countryLabel = requireLayer('country-label'); +countryLabel.filter = ['==', ['get', 'class'], 'country']; +countryLabel.layout['text-field'] = [ + 'coalesce', + ['get', 'name:en'], + ['get', 'name:latin'], + ['get', 'name'], + '' +]; + // Once detailed landuse takes over, do not multiply the exported color by another // zoom opacity. The color literal itself already contains any intended alpha. const landuse = requireLayer('landuse'); diff --git a/scripts/prepare-world-landcover.mjs b/scripts/prepare-world-landcover.mjs new file mode 100644 index 00000000..e494a4ce --- /dev/null +++ b/scripts/prepare-world-landcover.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (key === '--land') options.land = argv[++index]; + else if (key === '--geography') options.geography = argv[++index]; + else if (key === '--glaciers') options.glaciers = argv[++index]; + else if (key === '--output') options.output = argv[++index]; + } + for (const required of ['land', 'geography', 'glaciers', 'output']) { + if (!options[required]) throw new Error(`Missing --${required}.`); + } + return options; +} + +async function readCollection(filename) { + const collection = JSON.parse(await fs.readFile(filename, 'utf8')); + if (collection.type !== 'FeatureCollection' || !Array.isArray(collection.features)) { + throw new Error(`${filename} is not a GeoJSON FeatureCollection.`); + } + return collection; +} + +function classifiedFeature(feature, className) { + if (!feature?.geometry) return null; + return { + type: 'Feature', + properties: { class: className }, + geometry: feature.geometry, + tippecanoe: { + minzoom: 0, + maxzoom: 5 + } + }; +} + +const options = parseArgs(process.argv.slice(2)); +const [land, geography, glaciers] = await Promise.all([ + readCollection(options.land), + readCollection(options.geography), + readCollection(options.glaciers) +]); + +// The exact exported land chip is the neutral base. This generalized layer +// supplies the vegetation, desert, tundra, and ice classes that give the globe +// the same green physical identity before the detailed regional landcover +// takes over. It uses the same `landcover` schema as the regional shards. +const features = [ + ...land.features.map((feature) => classifiedFeature(feature, 'grass')), + ...geography.features + .filter((feature) => feature?.properties?.FEATURECLA === 'Desert') + .map((feature) => classifiedFeature(feature, 'sand')), + ...geography.features + .filter((feature) => feature?.properties?.FEATURECLA === 'Tundra') + .map((feature) => classifiedFeature(feature, 'scrub')), + ...glaciers.features.map((feature) => classifiedFeature(feature, 'snow')) +].filter(Boolean); + +await fs.writeFile( + options.output, + `${JSON.stringify({ type: 'FeatureCollection', features })}\n` +); + +const classCounts = features.reduce((counts, feature) => { + const className = feature.properties.class; + counts[className] = (counts[className] || 0) + 1; + return counts; +}, {}); +console.log(`Prepared generalized worldwide landcover: ${JSON.stringify(classCounts)}.`); diff --git a/src/occumed-map.js b/src/occumed-map.js index 59fdb159..37d78681 100644 --- a/src/occumed-map.js +++ b/src/occumed-map.js @@ -67,7 +67,7 @@ export async function createOccumedMap({ container, styleUrl = DEFAULT_STYLE_URL, center = [-98.5, 25], - zoom = 1.82, + zoom = 2.43, minZoom = 0, maxZoom = 16, controls = true, diff --git a/src/server/world-tile-gateway.js b/src/server/world-tile-gateway.js index 82da0d42..4fdae9fe 100644 --- a/src/server/world-tile-gateway.js +++ b/src/server/world-tile-gateway.js @@ -188,7 +188,7 @@ export class WorldTileGateway { if (zoom <= surfaceMaxZoom) { const payload = await this.readArchiveTile(surfaceAsset, zoom, x, y); return payload - ? mergeVectorTiles([payload], { includeLayers: ['land', 'depth'] }) + ? mergeVectorTiles([payload], { includeLayers: ['land', 'landcover', 'depth'] }) : EMPTY_MVT; } diff --git a/src/styles.css b/src/styles.css index 08fe8498..8928cc2a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -124,6 +124,21 @@ body { border-radius: 999px !important; } +.maplibregl-ctrl-attrib:not(.maplibregl-compact-show) { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; +} + +.maplibregl-ctrl-attrib:not(.maplibregl-compact-show) .maplibregl-ctrl-attrib-inner { + display: none; +} + +.maplibregl-ctrl-attrib:not(.maplibregl-compact-show) .maplibregl-ctrl-attrib-button { + display: block; +} + @media (max-width: 640px) { .map-header { top: 10px; From 6c7571dff03f679539e1005992cfc71eb20c4891 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 13:57:58 -0700 Subject: [PATCH 09/13] Render candidate world surface in PR checks --- .../workflows/build-virtual-world-tileset.yml | 55 +++++------------ scripts/build-world-surface.sh | 60 +++++++++++++++++++ scripts/check-pmtiles-integration.mjs | 10 +++- server.mjs | 3 + 4 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 scripts/build-world-surface.sh diff --git a/.github/workflows/build-virtual-world-tileset.yml b/.github/workflows/build-virtual-world-tileset.yml index 7fc7dcac..bd6d8072 100644 --- a/.github/workflows/build-virtual-world-tileset.yml +++ b/.github/workflows/build-virtual-world-tileset.yml @@ -14,6 +14,7 @@ on: - scripts/build-runtime-style.mjs - scripts/build-world-manifest.mjs - scripts/build-world-overview.mjs + - scripts/build-world-surface.sh - scripts/capture-world-fresno-preview.mjs - scripts/prepare-world-bathymetry.mjs - scripts/prepare-world-landcover.mjs @@ -38,6 +39,7 @@ on: - scripts/build-runtime-style.mjs - scripts/build-world-manifest.mjs - scripts/build-world-overview.mjs + - scripts/build-world-surface.sh - scripts/capture-world-fresno-preview.mjs - scripts/prepare-world-bathymetry.mjs - scripts/prepare-world-landcover.mjs @@ -203,46 +205,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential libsqlite3-dev zlib1g-dev - git clone --depth 1 --branch "${TIPPECANOE_VERSION}" \ - https://github.com/felt/tippecanoe.git virtual-world/tippecanoe - make -C virtual-world/tippecanoe -j2 - curl --fail --location --retry 5 \ - --output virtual-world/ne_10m_land.geojson \ - https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_land.geojson - curl --fail --location --retry 5 \ - --output virtual-world/ne_10m_geography_regions_polys.geojson \ - https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_geography_regions_polys.geojson - curl --fail --location --retry 5 \ - --output virtual-world/ne_10m_glaciated_areas.geojson \ - https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_glaciated_areas.geojson - mkdir -p virtual-world/bathymetry - for band in \ - A_10000 B_9000 C_8000 D_7000 E_6000 F_5000 \ - G_4000 H_3000 I_2000 J_1000 K_200 L_0; do - curl --fail --location --retry 5 \ - --output "virtual-world/bathymetry/ne_10m_bathymetry_${band}.geojson" \ - "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_bathymetry_${band}.geojson" - done - node scripts/prepare-world-bathymetry.mjs \ - --input-dir virtual-world/bathymetry \ - --output virtual-world/ne_10m_bathymetry.geojson - node scripts/prepare-world-landcover.mjs \ - --land virtual-world/ne_10m_land.geojson \ - --geography virtual-world/ne_10m_geography_regions_polys.geojson \ - --glaciers virtual-world/ne_10m_glaciated_areas.geojson \ - --output virtual-world/ne_10m_landcover.geojson - virtual-world/tippecanoe/tippecanoe \ - --force \ - --minimum-zoom=0 \ - --maximum-zoom=10 \ - --preserve-input-order \ - --no-feature-limit \ - --no-tile-size-limit \ - --no-simplification-of-shared-nodes \ - -L land:virtual-world/ne_10m_land.geojson \ - -L landcover:virtual-world/ne_10m_landcover.geojson \ - -L depth:virtual-world/ne_10m_bathymetry.geojson \ - --output="virtual-world/${SURFACE_ASSET}" + bash scripts/build-world-surface.sh "virtual-world/${SURFACE_ASSET}" - name: Validate virtual archives run: | @@ -347,11 +310,21 @@ jobs: - name: Install Chromium run: npx playwright install --with-deps chromium + - name: Build candidate worldwide physical surface + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential libsqlite3-dev zlib1g-dev + bash scripts/build-world-surface.sh \ + "dist/virtual-assets/${SURFACE_ASSET}" + - name: Render globe, regional, and street references run: | set -euo pipefail mkdir -p virtual-world - node server.mjs > virtual-world/browser-server.log 2>&1 & + OCCUMED_WORLD_SURFACE_URL="http://127.0.0.1:4173/virtual-assets/${SURFACE_ASSET}" \ + node server.mjs > virtual-world/browser-server.log 2>&1 & server_pid="$!" cleanup() { status="$?" diff --git a/scripts/build-world-surface.sh b/scripts/build-world-surface.sh new file mode 100644 index 00000000..4e0d1191 --- /dev/null +++ b/scripts/build-world-surface.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +output="${1:?Usage: build-world-surface.sh OUTPUT.pmtiles}" +tippecanoe_version="${TIPPECANOE_VERSION:-2.79.0}" +surface_workdir="$(mktemp -d /tmp/occumed-world-surface-XXXXXX)" +trap 'rm -rf "$surface_workdir"' EXIT + +output_dir="$(dirname "$output")" +mkdir -p "$output_dir" +output_path="$(cd "$output_dir" && pwd)/$(basename "$output")" + +git clone --depth 1 --branch "$tippecanoe_version" \ + https://github.com/felt/tippecanoe.git "$surface_workdir/tippecanoe" +make -C "$surface_workdir/tippecanoe" -j2 + +curl --fail --location --retry 5 \ + --output "$surface_workdir/ne_10m_land.geojson" \ + https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_land.geojson +curl --fail --location --retry 5 \ + --output "$surface_workdir/ne_10m_geography_regions_polys.geojson" \ + https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_geography_regions_polys.geojson +curl --fail --location --retry 5 \ + --output "$surface_workdir/ne_10m_glaciated_areas.geojson" \ + https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_glaciated_areas.geojson + +mkdir -p "$surface_workdir/bathymetry" +for band in \ + A_10000 B_9000 C_8000 D_7000 E_6000 F_5000 \ + G_4000 H_3000 I_2000 J_1000 K_200 L_0; do + curl --fail --location --retry 5 \ + --output "$surface_workdir/bathymetry/ne_10m_bathymetry_${band}.geojson" \ + "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_bathymetry_${band}.geojson" +done + +node scripts/prepare-world-bathymetry.mjs \ + --input-dir "$surface_workdir/bathymetry" \ + --output "$surface_workdir/ne_10m_bathymetry.geojson" +node scripts/prepare-world-landcover.mjs \ + --land "$surface_workdir/ne_10m_land.geojson" \ + --geography "$surface_workdir/ne_10m_geography_regions_polys.geojson" \ + --glaciers "$surface_workdir/ne_10m_glaciated_areas.geojson" \ + --output "$surface_workdir/ne_10m_landcover.geojson" + +"$surface_workdir/tippecanoe/tippecanoe" \ + --force \ + --minimum-zoom=0 \ + --maximum-zoom=10 \ + --preserve-input-order \ + --no-feature-limit \ + --no-tile-size-limit \ + --no-simplification-of-shared-nodes \ + -L "land:$surface_workdir/ne_10m_land.geojson" \ + -L "landcover:$surface_workdir/ne_10m_landcover.geojson" \ + -L "depth:$surface_workdir/ne_10m_bathymetry.geojson" \ + --output="$output_path" + +test -s "$output_path" +test "$(head -c 7 "$output_path")" = "PMTiles" +echo "Built worldwide physical surface at $output_path." diff --git a/scripts/check-pmtiles-integration.mjs b/scripts/check-pmtiles-integration.mjs index 02c7770d..b34597ec 100644 --- a/scripts/check-pmtiles-integration.mjs +++ b/scripts/check-pmtiles-integration.mjs @@ -13,6 +13,7 @@ const [ gateway, server, workflow, + surfaceBuilder, readme ] = await Promise.all([ fs.readFile(path.join(root, 'package.json'), 'utf8').then(JSON.parse), @@ -24,6 +25,7 @@ const [ fs.readFile(path.join(root, 'src/server/world-tile-gateway.js'), 'utf8'), fs.readFile(path.join(root, 'server.mjs'), 'utf8'), fs.readFile(path.join(root, '.github/workflows/build-virtual-world-tileset.yml'), 'utf8'), + fs.readFile(path.join(root, 'scripts/build-world-surface.sh'), 'utf8'), fs.readFile(path.join(root, 'README.md'), 'utf8') ]); @@ -64,6 +66,9 @@ if (!gateway.includes('Promise.all')) fail('The gateway cannot resolve intersect if (!gateway.includes('mergeVectorTiles')) fail('The gateway cannot merge MVT layers at shard boundaries.'); if (!gateway.includes('MemoryTileCache')) fail('Resolved worldwide tiles are not cached.'); if (!gateway.includes('overscaleVectorLayer')) fail('The land surface cannot remain continuous above its generalized zoom.'); +if (!server.includes('OCCUMED_WORLD_SURFACE_URL')) { + fail('Read-only visual validation cannot serve its candidate physical surface.'); +} if (!overviewBuilder.includes('mergeVectorTiles(payloads)')) { fail('The low-zoom overview is not consolidated from the regional archives.'); @@ -80,6 +85,7 @@ if (manifestBuilder.includes('archiveProxyTemplate')) fail('The manifest still a for (const marker of [ 'Build consolidated zoom 0-5 overview', 'Build worldwide physical surface', + 'Build candidate worldwide physical surface', 'prepare-world-bathymetry.mjs', 'ne_10m_bathymetry_${band}.geojson', 'Publish virtual storage archives', @@ -87,7 +93,9 @@ for (const marker of [ 'occumed-world-overview.pmtiles', 'occumed-world-surface.pmtiles' ]) { - if (!workflow.includes(marker)) fail(`Virtual tileset workflow is missing: ${marker}`); + if (!`${workflow}\n${surfaceBuilder}`.includes(marker)) { + fail(`Virtual tileset workflow is missing: ${marker}`); + } } for (const requiredLayer of [ diff --git a/server.mjs b/server.mjs index 958897b9..28b8ef88 100644 --- a/server.mjs +++ b/server.mjs @@ -13,6 +13,8 @@ const host = process.env.HOST?.trim() || '0.0.0.0'; const worldReleaseRepository = process.env.OCCUMED_WORLD_RELEASE_REPOSITORY?.trim() || 'Occumed79/Map'; const worldReleaseTag = process.env.OCCUMED_WORLD_RELEASE_TAG?.trim() || 'occumed-world-v1'; const worldManifestAsset = 'world-virtual-manifest.json'; +const worldSurfaceAsset = 'occumed-world-surface.pmtiles'; +const worldSurfaceUrl = process.env.OCCUMED_WORLD_SURFACE_URL?.trim(); const contentTypes = { '.css': 'text/css; charset=utf-8', @@ -87,6 +89,7 @@ async function serveStyle(request, response) { } function releaseAssetUrl(assetName) { + if (assetName === worldSurfaceAsset && worldSurfaceUrl) return worldSurfaceUrl; return `https://github.com/${worldReleaseRepository}/releases/download/${encodeURIComponent(worldReleaseTag)}/${encodeURIComponent(assetName)}`; } From b2464054d896339607efa82041e4289db55c30ad Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 14:16:15 -0700 Subject: [PATCH 10/13] Keep overview gaps from blanking tiles --- scripts/check-world-tile-gateway.mjs | 8 ++++++++ src/server/world-tile-gateway.js | 7 +++---- src/styles.css | 11 ++++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/scripts/check-world-tile-gateway.mjs b/scripts/check-world-tile-gateway.mjs index 3ba514e1..1c32653d 100644 --- a/scripts/check-world-tile-gateway.mjs +++ b/scripts/check-world-tile-gateway.mjs @@ -173,6 +173,14 @@ expect(server.includes("'world-virtual-manifest.json'"), 'The gateway does not u expect(!server.includes('/world-tiles/'), 'The server still exposes regional storage URLs to the browser.'); expect(gateway.includes('mergeVectorTiles'), 'The server gateway cannot merge boundary tiles.'); expect(gateway.includes('MemoryTileCache'), 'The server gateway does not cache resolved virtual tiles.'); +expect( + gateway.includes('return payload || EMPTY_MVT'), + 'A missing overview enrichment can still blank the independently resolved physical surface.' +); +expect( + !gateway.includes('Worldwide overview is missing tile'), + 'A missing overview enrichment can still reject the complete worldwide tile.' +); expect( gateway.includes("includeLayers: ['land', 'landcover', 'depth']"), 'The gateway does not expose land, landcover, and bathymetry as one continuous physical surface.' diff --git a/src/server/world-tile-gateway.js b/src/server/world-tile-gateway.js index 4fdae9fe..5a76b2d5 100644 --- a/src/server/world-tile-gateway.js +++ b/src/server/world-tile-gateway.js @@ -219,10 +219,9 @@ export class WorldTileGateway { } = manifest.virtualTiles; if (zoom <= overviewMaxZoom) { const payload = await this.readArchiveTile(overviewAsset, zoom, x, y); - if (!payload) { - throw new Error(`Worldwide overview is missing tile ${zoom}/${x}/${y}.`); - } - return payload; + // The physical surface is resolved independently and must remain visible + // even when an optional overview enrichment tile is absent. + return payload || EMPTY_MVT; } const regions = manifest.routingIndex.regionsForTile(zoom, x, y); diff --git a/src/styles.css b/src/styles.css index 8928cc2a..c7364f22 100644 --- a/src/styles.css +++ b/src/styles.css @@ -124,19 +124,20 @@ body { border-radius: 999px !important; } -.maplibregl-ctrl-attrib:not(.maplibregl-compact-show) { +.maplibregl-ctrl-bottom-right:not(:hover):not(:focus-within) .maplibregl-ctrl-attrib { width: 28px; min-width: 28px; height: 28px; padding: 0; + overflow: hidden; } -.maplibregl-ctrl-attrib:not(.maplibregl-compact-show) .maplibregl-ctrl-attrib-inner { - display: none; +.maplibregl-ctrl-bottom-right:not(:hover):not(:focus-within) .maplibregl-ctrl-attrib-inner { + display: none !important; } -.maplibregl-ctrl-attrib:not(.maplibregl-compact-show) .maplibregl-ctrl-attrib-button { - display: block; +.maplibregl-ctrl-bottom-right:not(:hover):not(:focus-within) .maplibregl-ctrl-attrib-button { + display: block !important; } @media (max-width: 640px) { From 706ea35587b3f19cd12770273ed7e76da26a0102 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 14:30:21 -0700 Subject: [PATCH 11/13] Normalize oversized vector attributes --- scripts/check-world-tile-gateway.mjs | 12 ++++++++++++ src/server/mvt.js | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/check-world-tile-gateway.mjs b/scripts/check-world-tile-gateway.mjs index 1c32653d..eff863f1 100644 --- a/scripts/check-world-tile-gateway.mjs +++ b/scripts/check-world-tile-gateway.mjs @@ -4,6 +4,7 @@ import vtpbf from 'vt-pbf'; import { inspectVectorTile, mergeVectorTiles, + normalizeMvtProperties, overscaleVectorLayer } from '../src/server/mvt.js'; import { @@ -109,6 +110,17 @@ const physicalSurface = inspectVectorTile( expect(physicalSurface.land?.featureCount === 1, 'The physical surface lost its land layer.'); expect(physicalSurface.landcover?.featureCount === 1, 'The physical surface lost generalized landcover.'); expect(physicalSurface.depth?.featureCount === 1, 'The physical surface lost its bathymetry layer.'); +const normalizedProperties = normalizeMvtProperties({ + safeRank: 4, + unsafePositive: 2 ** 65, + unsafeNegative: -(2 ** 65) +}); +expect(normalizedProperties.safeRank === 4, 'A safe numeric style attribute changed type.'); +expect( + normalizedProperties.unsafePositive === String(2 ** 65) && + normalizedProperties.unsafeNegative === String(-(2 ** 65)), + 'Unsupported MVT property integers were not preserved as strings.' +); const regions = [ { diff --git a/src/server/mvt.js b/src/server/mvt.js index 39055301..483bbd71 100644 --- a/src/server/mvt.js +++ b/src/server/mvt.js @@ -16,6 +16,21 @@ function stableProperties(properties) { .map((key) => [key, properties[key]]); } +export function normalizeMvtProperties(properties) { + return Object.fromEntries( + Object.entries(properties || {}).map(([key, value]) => { + if (typeof value === 'bigint') return [key, value.toString()]; + if ( + typeof value === 'number' && + (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) + ) { + return [key, String(value)]; + } + return [key, value]; + }) + ); +} + function geometrySignature(feature) { const hash = createHash('sha1'); hash.update(`${feature.type}|${JSON.stringify(stableProperties(feature.properties))}|`); @@ -105,7 +120,7 @@ class CombinedFeature { constructor(feature) { this.id = feature.id; this.type = feature.type; - this.properties = feature.properties; + this.properties = normalizeMvtProperties(feature.properties); this.extent = feature.extent; this.geometry = feature.loadGeometry(); } @@ -192,7 +207,7 @@ class TransformedFeature { constructor(feature, scale, offsetX, offsetY, extent) { this.id = feature.id; this.type = feature.type; - this.properties = feature.properties; + this.properties = normalizeMvtProperties(feature.properties); this.extent = extent; this.source = feature; this.scale = scale; From c9239bc678e123784314f6e4c18ca3c7d2b126e8 Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 14:36:05 -0700 Subject: [PATCH 12/13] Match globe label hierarchy --- scripts/apply-schema-parity.mjs | 16 ++++++++++++++- scripts/check-exact-exported-swatches.mjs | 24 +++++++++++++++++++++-- scripts/lock-exact-exported-swatches.mjs | 17 +++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/scripts/apply-schema-parity.mjs b/scripts/apply-schema-parity.mjs index c7b4629e..19f6a112 100644 --- a/scripts/apply-schema-parity.mjs +++ b/scripts/apply-schema-parity.mjs @@ -99,7 +99,21 @@ for (const layer of runtime.layers || []) { const placeFilters = { 'continent-label': ['==', get('class'), 'continent'], - 'country-label': ['==', get('class'), 'country'], + 'country-label': [ + 'all', + ['==', get('class'), 'country'], + [ + 'step', + ['zoom'], + ['<=', ['coalesce', get('rank'), 99], 3], + 2.5, + ['<=', ['coalesce', get('rank'), 99], 4], + 3.5, + ['<=', ['coalesce', get('rank'), 99], 5], + 5, + true + ] + ], 'state-label': ['match', get('class'), ['state', 'province'], true, false], 'settlement-major-label': ['match', get('class'), ['city', 'town'], true, false], 'settlement-minor-label': ['match', get('class'), ['village', 'hamlet'], true, false], diff --git a/scripts/check-exact-exported-swatches.mjs b/scripts/check-exact-exported-swatches.mjs index 79f97c70..32f0574e 100644 --- a/scripts/check-exact-exported-swatches.mjs +++ b/scripts/check-exact-exported-swatches.mjs @@ -88,9 +88,29 @@ for (const [name, value] of Object.entries({ expect(layer('landcover')?.minzoom === 0, 'Landcover no longer begins at globe zoom.'); expect(layer('continent-label')?.layout?.visibility === 'none', 'Multilingual continent labels returned to the globe limb.'); +const expectedCountryFilter = [ + 'all', + ['==', ['get', 'class'], 'country'], + [ + 'step', + ['zoom'], + ['<=', ['coalesce', ['get', 'rank'], 99], 3], + 2.5, + ['<=', ['coalesce', ['get', 'rank'], 99], 4], + 3.5, + ['<=', ['coalesce', ['get', 'rank'], 99], 5], + 5, + true + ] +]; expect( - JSON.stringify(layer('country-label')?.filter || []) === JSON.stringify(['==', ['get', 'class'], 'country']), - 'The clean country-label filter changed.' + JSON.stringify(layer('country-label')?.filter || []) === JSON.stringify(expectedCountryFilter), + 'The globe country-label hierarchy changed.' +); +expect( + JSON.stringify(layer('country-label')?.layout?.['symbol-sort-key']) === + JSON.stringify(['coalesce', ['get', 'rank'], 99]), + 'Major countries no longer receive deterministic placement priority.' ); expect(color('occumed-land-surface', 'fill-opacity') === 1, 'The worldwide land surface is translucent.'); expect(color('landuse', 'fill-opacity') === 1, 'Detailed landuse colors are weakened.'); diff --git a/scripts/lock-exact-exported-swatches.mjs b/scripts/lock-exact-exported-swatches.mjs index 80a4a3a6..5018992b 100644 --- a/scripts/lock-exact-exported-swatches.mjs +++ b/scripts/lock-exact-exported-swatches.mjs @@ -95,7 +95,21 @@ continentLabel.layout ||= {}; continentLabel.layout.visibility = 'none'; const countryLabel = requireLayer('country-label'); -countryLabel.filter = ['==', ['get', 'class'], 'country']; +countryLabel.filter = [ + 'all', + ['==', ['get', 'class'], 'country'], + [ + 'step', + ['zoom'], + ['<=', ['coalesce', ['get', 'rank'], 99], 3], + 2.5, + ['<=', ['coalesce', ['get', 'rank'], 99], 4], + 3.5, + ['<=', ['coalesce', ['get', 'rank'], 99], 5], + 5, + true + ] +]; countryLabel.layout['text-field'] = [ 'coalesce', ['get', 'name:en'], @@ -103,6 +117,7 @@ countryLabel.layout['text-field'] = [ ['get', 'name'], '' ]; +countryLabel.layout['symbol-sort-key'] = ['coalesce', ['get', 'rank'], 99]; // Once detailed landuse takes over, do not multiply the exported color by another // zoom opacity. The color literal itself already contains any intended alpha. From dd6c3ab2623e6498768544f8e3ec4415d7e5c4cd Mon Sep 17 00:00:00 2001 From: Occumed79 Date: Mon, 27 Jul 2026 14:49:50 -0700 Subject: [PATCH 13/13] Wait for fresh viewport tiles --- scripts/capture-world-fresno-preview.mjs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/capture-world-fresno-preview.mjs b/scripts/capture-world-fresno-preview.mjs index 43aa3f28..ca257645 100644 --- a/scripts/capture-world-fresno-preview.mjs +++ b/scripts/capture-world-fresno-preview.mjs @@ -79,10 +79,30 @@ try { { nextCenter: center, nextZoom: zoom } ); + // A synchronous jump can briefly report the previous viewport's tile state + // before MapLibre has rendered the new transform. Require two new animation + // frames before observing source readiness. + await page.evaluate(() => new Promise((resolve) => { + const map = globalThis.__OCCUMED_MAP__; + let frames = 0; + const nextFrame = () => { + map.triggerRepaint(); + requestAnimationFrame(() => { + frames += 1; + if (frames >= 2) resolve(); + else nextFrame(); + }); + }; + nextFrame(); + })); + await page.waitForFunction( () => { const map = globalThis.__OCCUMED_MAP__; - return map?.isStyleLoaded() && map.areTilesLoaded(); + if (!map?.isStyleLoaded() || !map.areTilesLoaded()) return false; + return map + .queryRenderedFeatures() + .some((feature) => feature.source === 'occumed-open'); }, null, { timeout: 90_000 }