feat: v0.4.1 — untrack map-asset pack (~100 MB): compose volume mount + runtime fallback to remote OFM styles
This commit is contained in:
@@ -14,3 +14,9 @@ playwright.config.ts
|
|||||||
*.md
|
*.md
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
docker-compose*
|
docker-compose*
|
||||||
|
# generated map-asset pack (~100 MB): arrives at runtime via the compose volume
|
||||||
|
# mount ./public → /app/public, never baked into the image (v0.4.1)
|
||||||
|
public/styles
|
||||||
|
public/fonts
|
||||||
|
public/sprites
|
||||||
|
public/map-assets-manifest.json
|
||||||
|
|||||||
@@ -16,3 +16,10 @@ test-results/
|
|||||||
# runtime DB files in data/ are ignored; CSV datasets are committed seed data
|
# runtime DB files in data/ are ignored; CSV datasets are committed seed data
|
||||||
data/*.db
|
data/*.db
|
||||||
data/*.sqlite*
|
data/*.sqlite*
|
||||||
|
|
||||||
|
# generated map-asset pack (~100 MB) — produced by `npm run assets:download`,
|
||||||
|
# served via the docker-compose volume mount, never tracked (v0.4.1)
|
||||||
|
public/styles/
|
||||||
|
public/fonts/
|
||||||
|
public/sprites/
|
||||||
|
public/map-assets-manifest.json
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.4.1 — 2026-09-17 08:50
|
||||||
|
- Map-asset pack (~100 MB, 780 files) removed from git and from the Docker image: `git rm -r --cached public/{styles,fonts,sprites}` (files kept on disk), `.gitignore` now excludes exactly the generated paths (`public/styles/`, `public/fonts/`, `public/sprites/`, `public/map-assets-manifest.json`) — not all of `public/`; `.dockerignore` gets the same four entries; the v0.4.0 runtime-stage `COPY --from=build /app/public ./public` line is gone from the Dockerfile
|
||||||
|
- The pack now reaches the container via a docker-compose volume mount: `./public:/app/public` on the `flight` service — host dir present with assets → served locally; absent/empty → 404s → runtime fallback (below). Establishes the pattern for the future multi-GB tile file (v0.5.0) that must never go into git
|
||||||
|
- `scripts/download-map-assets.mjs` now writes `public/map-assets-manifest.json` as its LAST step (`{name, generatedAt ISO, styles: [ids]}`) so the manifest's presence proves the whole pack is in place; new npm script `"assets:download": "node scripts/download-map-assets.mjs"`
|
||||||
|
- Runtime fallback for fresh clones without the pack: `lib/mapStyles.ts` gains pure `styleUrlFor(id, localAssetsAvailable)` → `/styles/{id}.json` or `https://tiles.openfreemap.org/styles/{id}`, plus async `localAssetsAvailable()` — fetches `/map-assets-manifest.json` once (module-level cached promise; any non-200/network error → false); the single manifest probe is designed so a future self-hosted tile pack plugs into the same availability mechanism without rework
|
||||||
|
- `components/Map.tsx`: map creation in the mount effect is deferred until the probe resolves (one cheap local request before `new Map`), and the persisted-style restore was folded into that same post-mount flow — localStorage is still read only after mount (SSR-safe), applied at creation time so there's no fiord→saved flash; style switcher changes reuse the cached probe via `styleUrlFor`; all v0.4.0 behavior intact (fiord default, persistence key, idempotent `ensureAirportsLayer` on every `styledata`, single `/api/airports` fetch, `window.__flightMap`)
|
||||||
|
- Tests: unit — new `styleUrlFor` cases (local path / exact remote OFM URL) and `localAssetsAvailable` cases (false on non-200, true + cached with exactly one fetch across awaits via module reset, false on network error); e2e — new test "falls back to remote OFM styles when the local asset pack is absent" routes `/map-assets-manifest.json` → 404, lets `*.openfreemap.org/styles/**` through while aborting all other OFM traffic, and asserts the map initializes from the REMOTE style (glyphs still point at openfreemap.org), the airport count renders, and a UKBB marker click opens its popup — proving fresh-clone-without-assets usability end-to-end
|
||||||
|
- Verified: unit suite 21/21; `tsc --noEmit` clean; e2e suite green against the rebuilt compose instance with assets served from the bind mount (curl: `/styles/fiord.json`, a font `.pbf`, `/sprites/ofm.json` all 200); note the asset blobs remain in git history — no history rewrite was performed
|
||||||
|
|
||||||
## v0.4.0 — 2026-09-17 08:22
|
## v0.4.0 — 2026-09-17 08:22
|
||||||
- Self-hosted all OpenFreeMap map-styling assets (tiles stay remote): new `scripts/download-map-assets.mjs` fetches the 5 OFM styles with a browser UA, rewrites each via `rewriteStyle`, and extracts the font/sprite tarballs into `public/` — committed so the image is self-contained: `public/styles/{liberty,bright,positron,dark,fiord}.json` (0.15 MB), `public/fonts/Noto Sans {Regular,Italic,Bold}/*.pbf` (768 files, 99.76 MB — the font payload), `public/sprites/ofm{.json,.png,@2x.json,@2x.png}` (0.21 MB); total public/ = 100.13 MB
|
- Self-hosted all OpenFreeMap map-styling assets (tiles stay remote): new `scripts/download-map-assets.mjs` fetches the 5 OFM styles with a browser UA, rewrites each via `rewriteStyle`, and extracts the font/sprite tarballs into `public/` — committed so the image is self-contained: `public/styles/{liberty,bright,positron,dark,fiord}.json` (0.15 MB), `public/fonts/Noto Sans {Regular,Italic,Bold}/*.pbf` (768 files, 99.76 MB — the font payload), `public/sprites/ofm{.json,.png,@2x.json,@2x.png}` (0.21 MB); total public/ = 100.13 MB
|
||||||
- New shared module `lib/mapStyles.ts`: `STYLES` registry (5 ids + labels), `DEFAULT_STYLE_ID = 'fiord'`, and pure `rewriteStyle()` — glyphs `https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf` → `/fonts/{fontstack}/{range}.pbf`, sprite `https://tiles.openfreemap.org/sprites/ofm_f384/ofm` → `/sprites/ofm`; the vector tile url (`/planet`) and ne2_shaded raster tiles (`/natural_earth/ne2sr/...`) are left remote; every source lacking an attribution gets `"© OpenStreetMap contributors © OpenFreeMap"` (OFM styles ship none; ODbL requires visible OSM credit)
|
- New shared module `lib/mapStyles.ts`: `STYLES` registry (5 ids + labels), `DEFAULT_STYLE_ID = 'fiord'`, and pure `rewriteStyle()` — glyphs `https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf` → `/fonts/{fontstack}/{range}.pbf`, sprite `https://tiles.openfreemap.org/sprites/ofm_f384/ofm` → `/sprites/ofm`; the vector tile url (`/planet`) and ne2_shaded raster tiles (`/natural_earth/ne2sr/...`) are left remote; every source lacking an attribution gets `"© OpenStreetMap contributors © OpenFreeMap"` (OFM styles ship none; ODbL requires visible OSM credit)
|
||||||
|
|||||||
+3
-2
@@ -30,7 +30,8 @@ COPY --from=prod-deps /app/node_modules ./node_modules
|
|||||||
COPY --from=build /app/.next ./.next
|
COPY --from=build /app/.next ./.next
|
||||||
COPY --from=build /app/package.json ./package.json
|
COPY --from=build /app/package.json ./package.json
|
||||||
COPY --from=build /app/data ./data
|
COPY --from=build /app/data ./data
|
||||||
# Self-hosted map assets (styles/fonts/sprites) — next start serves public/ from disk.
|
# No public/ in the image: the generated map-asset pack (~100 MB, not in git) is
|
||||||
COPY --from=build /app/public ./public
|
# volume-mounted by docker-compose as ./public → /app/public; without it the app
|
||||||
|
# falls back to remote openfreemap.org styles at runtime.
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
+92
-84
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
|||||||
import * as maplibregl from 'maplibre-gl';
|
import * as maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import pkg from '../package.json';
|
import pkg from '../package.json';
|
||||||
import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, styleUrl } from '../lib/mapStyles';
|
import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvailable, styleUrlFor } from '../lib/mapStyles';
|
||||||
|
|
||||||
const EUROPE_CENTER: [number, number] = [10, 52];
|
const EUROPE_CENTER: [number, number] = [10, 52];
|
||||||
const INITIAL_ZOOM = 4;
|
const INITIAL_ZOOM = 4;
|
||||||
@@ -36,105 +36,113 @@ export default function Map() {
|
|||||||
// route that serves the worker script (and its relative imports) from node_modules.
|
// route that serves the worker script (and its relative imports) from node_modules.
|
||||||
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
|
maplibregl.setWorkerUrl('/api/mlworker/maplibre-gl-worker.mjs');
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
let cancelled = false;
|
||||||
container: containerRef.current,
|
|
||||||
style: styleUrl(DEFAULT_STYLE_ID), // self-hosted style (public/styles/)
|
|
||||||
center: EUROPE_CENTER,
|
|
||||||
zoom: INITIAL_ZOOM,
|
|
||||||
minZoom: MIN_ZOOM,
|
|
||||||
maxZoom: MAX_ZOOM,
|
|
||||||
});
|
|
||||||
mapRef.current = map;
|
|
||||||
// Dev/e2e hook so tests can project coordinates to screen space.
|
|
||||||
(window as unknown as Record<string, unknown>).__flightMap = map;
|
|
||||||
|
|
||||||
const onAirportClick = (e: maplibregl.MapLayerMouseEvent) => {
|
// Defer map creation until the asset-pack probe resolves — one cheap local
|
||||||
const f = e.features?.[0];
|
// request decides between self-hosted styles (volume-mounted public/) and the
|
||||||
if (!f) return;
|
// remote openfreemap.org fallback. The persisted style choice is read here,
|
||||||
const p = f.properties as { name: string; icao: string };
|
// post-mount: SSR hydration stays safe (server + first paint use the default),
|
||||||
new maplibregl.Popup({ offset: 10 })
|
// and applying it at creation time avoids a fiord→saved-style flash.
|
||||||
.setLngLat(f.geometry.coordinates as [number, number])
|
void localAssetsAvailable().then((available) => {
|
||||||
.setHTML(
|
if (cancelled || !containerRef.current) return;
|
||||||
`<div data-testid="airport-popup"><strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}</div>`,
|
|
||||||
)
|
|
||||||
.addTo(map);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAirports = () => {
|
const saved = window.localStorage.getItem(STYLE_STORAGE_KEY);
|
||||||
if (!airportsPromiseRef.current) {
|
const initialId = saved && isStyleId(saved) ? saved : DEFAULT_STYLE_ID;
|
||||||
airportsPromiseRef.current = fetch('/api/airports')
|
setStyleId(initialId);
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
|
const map = new maplibregl.Map({
|
||||||
return res.json() as Promise<{ features: unknown[] }>;
|
container: containerRef.current,
|
||||||
})
|
style: styleUrlFor(initialId, available), // local pack or remote OFM fallback
|
||||||
.then((data) => {
|
center: EUROPE_CENTER,
|
||||||
setAirportCount(data.features.length); // exactly once per page life
|
zoom: INITIAL_ZOOM,
|
||||||
return data;
|
minZoom: MIN_ZOOM,
|
||||||
|
maxZoom: MAX_ZOOM,
|
||||||
|
});
|
||||||
|
mapRef.current = map;
|
||||||
|
// Dev/e2e hook so tests can project coordinates to screen space.
|
||||||
|
(window as unknown as Record<string, unknown>).__flightMap = map;
|
||||||
|
|
||||||
|
const onAirportClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
const f = e.features?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const p = f.properties as { name: string; icao: string };
|
||||||
|
new maplibregl.Popup({ offset: 10 })
|
||||||
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(
|
||||||
|
`<div data-testid="airport-popup"><strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}</div>`,
|
||||||
|
)
|
||||||
|
.addTo(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAirports = () => {
|
||||||
|
if (!airportsPromiseRef.current) {
|
||||||
|
airportsPromiseRef.current = fetch('/api/airports')
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`);
|
||||||
|
return res.json() as Promise<{ features: unknown[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setAirportCount(data.features.length); // exactly once per page life
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return airportsPromiseRef.current;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idempotent, so it can run on EVERY styledata event: map.setStyle() removes all
|
||||||
|
// sources/layers not in the new style, which wipes our GeoJSON layer after each
|
||||||
|
// switch — this re-establishes it (and the initial load goes through here too).
|
||||||
|
const ensureAirportsLayer = async () => {
|
||||||
|
let data: { features: unknown[] };
|
||||||
|
try {
|
||||||
|
data = await loadAirports();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load airports', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!map.getSource(AIRPORTS_SOURCE_ID)) {
|
||||||
|
map.addSource(AIRPORTS_SOURCE_ID, { type: 'geojson', data });
|
||||||
|
}
|
||||||
|
if (!map.getLayer(AIRPORTS_LAYER_ID)) {
|
||||||
|
map.addLayer({
|
||||||
|
id: AIRPORTS_LAYER_ID,
|
||||||
|
type: 'circle',
|
||||||
|
source: AIRPORTS_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 3,
|
||||||
|
'circle-color': '#e11d48',
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return airportsPromiseRef.current;
|
// Layer-scoped listeners survive setStyle (they filter by layer id at event
|
||||||
};
|
// time), so off+on with the same reference re-wires without duplicating.
|
||||||
|
map.off('click', AIRPORTS_LAYER_ID, onAirportClick);
|
||||||
|
map.on('click', AIRPORTS_LAYER_ID, onAirportClick);
|
||||||
|
};
|
||||||
|
|
||||||
// Idempotent, so it can run on EVERY styledata event: map.setStyle() removes all
|
if (map.isStyleLoaded()) void ensureAirportsLayer();
|
||||||
// sources/layers not in the new style, which wipes our GeoJSON layer after each
|
else map.on('styledata', () => void ensureAirportsLayer());
|
||||||
// switch — this re-establishes it (and the initial load goes through here too).
|
});
|
||||||
const ensureAirportsLayer = async () => {
|
|
||||||
let data: { features: unknown[] };
|
|
||||||
try {
|
|
||||||
data = await loadAirports();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to load airports', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!map.getSource(AIRPORTS_SOURCE_ID)) {
|
|
||||||
map.addSource(AIRPORTS_SOURCE_ID, { type: 'geojson', data });
|
|
||||||
}
|
|
||||||
if (!map.getLayer(AIRPORTS_LAYER_ID)) {
|
|
||||||
map.addLayer({
|
|
||||||
id: AIRPORTS_LAYER_ID,
|
|
||||||
type: 'circle',
|
|
||||||
source: AIRPORTS_SOURCE_ID,
|
|
||||||
paint: {
|
|
||||||
'circle-radius': 3,
|
|
||||||
'circle-color': '#e11d48',
|
|
||||||
'circle-stroke-width': 1,
|
|
||||||
'circle-stroke-color': '#ffffff',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Layer-scoped listeners survive setStyle (they filter by layer id at event
|
|
||||||
// time), so off+on with the same reference re-wires without duplicating.
|
|
||||||
map.off('click', AIRPORTS_LAYER_ID, onAirportClick);
|
|
||||||
map.on('click', AIRPORTS_LAYER_ID, onAirportClick);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (map.isStyleLoaded()) void ensureAirportsLayer();
|
|
||||||
else map.on('styledata', () => void ensureAirportsLayer());
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
delete (window as unknown as Record<string, unknown>).__flightMap;
|
delete (window as unknown as Record<string, unknown>).__flightMap;
|
||||||
|
const map = mapRef.current; // may be null if the probe hasn't resolved yet
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
map.remove();
|
if (map) map.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Restore the persisted style choice once after mount. Reading localStorage here —
|
|
||||||
// not during render or in a useState initializer — keeps SSR hydration safe: server
|
|
||||||
// and first client paint both use DEFAULT_STYLE_ID, then we switch if needed.
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = window.localStorage.getItem(STYLE_STORAGE_KEY);
|
|
||||||
if (saved && isStyleId(saved) && saved !== DEFAULT_STYLE_ID) {
|
|
||||||
setStyleId(saved);
|
|
||||||
mapRef.current?.setStyle(styleUrl(saved));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onStyleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const onStyleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const id = e.target.value;
|
const id = e.target.value;
|
||||||
if (!isStyleId(id)) return;
|
if (!isStyleId(id)) return;
|
||||||
setStyleId(id);
|
setStyleId(id);
|
||||||
window.localStorage.setItem(STYLE_STORAGE_KEY, id);
|
window.localStorage.setItem(STYLE_STORAGE_KEY, id);
|
||||||
mapRef.current?.setStyle(styleUrl(id));
|
// The probe promise is cached module-wide and has resolved by the time a user
|
||||||
|
// interacts — this just picks local vs remote for the new style.
|
||||||
|
void localAssetsAvailable().then((available) => mapRef.current?.setStyle(styleUrlFor(id, available)));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
flight:
|
flight:
|
||||||
build: .
|
build: .
|
||||||
|
# Generated map-asset pack (~100 MB, not in git/image): `npm run assets:download`
|
||||||
|
# populates ./public; absent/empty → app falls back to remote OFM styles.
|
||||||
|
volumes:
|
||||||
|
- "./public:/app/public"
|
||||||
networks:
|
networks:
|
||||||
- dev_net
|
- dev_net
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -214,3 +214,48 @@ test('style choice persists across reloads via localStorage', async ({ page }) =
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(select).toHaveValue('positron');
|
await expect(select).toHaveValue('positron');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('falls back to remote OFM styles when the local asset pack is absent', async ({ page }) => {
|
||||||
|
// Simulates a fresh clone without the generated asset pack (no volume mount):
|
||||||
|
// the manifest probe gets a 404, so the app must load its style from
|
||||||
|
// openfreemap.org. Only style fetches are allowed through; all other OFM traffic
|
||||||
|
// (tiles/fonts/sprites) is aborted for speed — the GeoJSON airport layer needs none of it.
|
||||||
|
// Routes match in reverse declaration order: manifest 404 > styles continue > abort-all.
|
||||||
|
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
|
||||||
|
await page.route('**://*.openfreemap.org/styles/**', (route) => route.continue());
|
||||||
|
await page.route('**/map-assets-manifest.json', (route) => route.fulfill({ status: 404 }));
|
||||||
|
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
|
||||||
|
// The map must have been created from the REMOTE style — its glyphs still point at OFM.
|
||||||
|
const glyphs = () => page.evaluate(() => (window as any).__flightMap?.getStyle()?.glyphs ?? '');
|
||||||
|
await expect.poll(glyphs, { timeout: 15_000 }).toContain('tiles.openfreemap.org');
|
||||||
|
|
||||||
|
// The app is fully usable without the pack: airport count renders…
|
||||||
|
const count = await waitForAirportCount(page);
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// …and markers are rendered and clickable.
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as any).__flightMap?.queryRenderedFeatures(undefined, {
|
||||||
|
layers: ['airports-circles'],
|
||||||
|
})?.length ?? 0,
|
||||||
|
),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const pt = await page.evaluate(() => {
|
||||||
|
const map = (window as any).__flightMap;
|
||||||
|
const p = map.project([30.894699, 50.345001]); // UKBB Boryspil
|
||||||
|
const rect = map.getContainer().getBoundingClientRect();
|
||||||
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||||
|
});
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
|
||||||
|
const popup = page.getByTestId('airport-popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await expect(popup).toContainText('Boryspil International Airport');
|
||||||
|
await expect(popup).toContainText('UKBB');
|
||||||
|
});
|
||||||
|
|||||||
+48
-2
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { DEFAULT_STYLE_ID, STYLES, isStyleId, rewriteStyle, styleUrl } from './mapStyles';
|
import { DEFAULT_STYLE_ID, REMOTE_STYLE_BASE, STYLES, isStyleId, rewriteStyle, styleUrl, styleUrlFor } from './mapStyles';
|
||||||
|
|
||||||
// Mirrors the real OFM style JSONs (verified against tiles.openfreemap.org): same
|
// Mirrors the real OFM style JSONs (verified against tiles.openfreemap.org): same
|
||||||
// glyphs/sprite values and source shapes, no attribution anywhere.
|
// glyphs/sprite values and source shapes, no attribution anywhere.
|
||||||
@@ -76,3 +76,49 @@ describe('rewriteStyle', () => {
|
|||||||
expect(JSON.stringify(RAW_OFM_STYLE)).toBe(before);
|
expect(JSON.stringify(RAW_OFM_STYLE)).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('styleUrlFor', () => {
|
||||||
|
it('points at the local style file when the asset pack is available', () => {
|
||||||
|
for (const s of STYLES) {
|
||||||
|
expect(styleUrlFor(s.id, true)).toBe(`/styles/${s.id}.json`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points at the exact remote OFM style url otherwise', () => {
|
||||||
|
for (const s of STYLES) {
|
||||||
|
expect(styleUrlFor(s.id, false)).toBe(`${REMOTE_STYLE_BASE}/${s.id}`);
|
||||||
|
expect(styleUrlFor(s.id, false)).toBe(`https://tiles.openfreemap.org/styles/${s.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localAssetsAvailable', () => {
|
||||||
|
// The probe caches its promise at module level, so each test gets a fresh module
|
||||||
|
// instance (resetModules + dynamic import) with its own stubbed fetch.
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false on non-200 responses', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
||||||
|
const mod = await import('./mapStyles');
|
||||||
|
await expect(mod.localAssetsAvailable()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves true on 200 and caches the probe (one fetch across awaits)', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const mod = await import('./mapStyles');
|
||||||
|
const [a, b] = await Promise.all([mod.localAssetsAvailable(), mod.localAssetsAvailable()]);
|
||||||
|
expect(a).toBe(true);
|
||||||
|
expect(b).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false on network errors', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||||
|
const mod = await import('./mapStyles');
|
||||||
|
await expect(mod.localAssetsAvailable()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,33 @@ export function styleUrl(id: string): string {
|
|||||||
return `/styles/${id}.json`;
|
return `/styles/${id}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remote fallback base for styles when the local asset pack is absent (fresh clone
|
||||||
|
// without `npm run assets:download` / no volume mount). The raw OFM styles already
|
||||||
|
// point glyphs/sprites at remote URLs, so they work as-is — only vector tiles were
|
||||||
|
// ever remote. A future self-hosted tile pack plugs into the same availability
|
||||||
|
// mechanism below (the manifest describes the whole local asset set).
|
||||||
|
export const REMOTE_STYLE_BASE = 'https://tiles.openfreemap.org/styles';
|
||||||
|
|
||||||
|
export function styleUrlFor(id: string, localAssetsAvailable: boolean): string {
|
||||||
|
return localAssetsAvailable ? `/styles/${id}.json` : `${REMOTE_STYLE_BASE}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manifest is written LAST by scripts/download-map-assets.mjs, so its presence
|
||||||
|
// means the whole pack (styles + fonts + sprites — and any future asset class) is in
|
||||||
|
// place. Probed once per page life via a module-level cached promise: any non-200 or
|
||||||
|
// network error → false (fall back to remote styles).
|
||||||
|
const MANIFEST_URL = '/map-assets-manifest.json';
|
||||||
|
let assetsProbePromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
export function localAssetsAvailable(): Promise<boolean> {
|
||||||
|
if (!assetsProbePromise) {
|
||||||
|
assetsProbePromise = fetch(MANIFEST_URL, { cache: 'no-store' })
|
||||||
|
.then((res) => res.ok)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
return assetsProbePromise;
|
||||||
|
}
|
||||||
|
|
||||||
type Source = Record<string, unknown>;
|
type Source = Record<string, unknown>;
|
||||||
|
|
||||||
// Takes a raw OFM style object and returns a new one with glyphs/sprite pointed at the
|
// Takes a raw OFM style object and returns a new one with glyphs/sprite pointed at the
|
||||||
|
|||||||
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
|
"assets:download": "node scripts/download-map-assets.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
´
|
|
||||||
¤Noto Sans Bold, Noto Naskh Arabic Bold, Noto Sans Armenian Bold, Noto Sans Balinese Regular, Noto Sans Bengali Bold, Noto Sans CJK TC Bold, Noto Sans Devanagari Bold, Noto Sans Ethiopic Bold, Noto Sans Georgian Bold, Noto Sans Gujarati Bold, Noto Sans Gurmukhi Bold, Noto Sans Hebrew Bold, Noto Sans Javanese Regular, Noto Sans Kannada Bold, Noto Sans Khmer Bold, Noto Sans Lao Bold, Noto Sans Mongolian Regular, Noto Sans Myanmar Bold, Noto Sans Oriya Bold, Noto Sans Sinhala Bold, Noto Sans Tamil Bold, Noto Sans Thai Bold, Noto Sans Tibetan Bold10240-10495
|
|
||||||
Binary file not shown.
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
´
|
|
||||||
¤Noto Sans Bold, Noto Naskh Arabic Bold, Noto Sans Armenian Bold, Noto Sans Balinese Regular, Noto Sans Bengali Bold, Noto Sans CJK TC Bold, Noto Sans Devanagari Bold, Noto Sans Ethiopic Bold, Noto Sans Georgian Bold, Noto Sans Gujarati Bold, Noto Sans Gurmukhi Bold, Noto Sans Hebrew Bold, Noto Sans Javanese Regular, Noto Sans Kannada Bold, Noto Sans Khmer Bold, Noto Sans Lao Bold, Noto Sans Mongolian Regular, Noto Sans Myanmar Bold, Noto Sans Oriya Bold, Noto Sans Sinhala Bold, Noto Sans Tamil Bold, Noto Sans Thai Bold, Noto Sans Tibetan Bold10752-11007
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user