feat: v0.4.0 — self-host OFM styles/fonts/sprites (tiles stay remote) + top-bar style switcher defaulting to Fiord
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- 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)
|
||||||
|
- `components/Map.tsx`: map now loads the local `/styles/fiord.json` by default; new top-bar style switcher (`<select data-testid="style-switch">`, subtle styling in `app/globals.css`) listing all 5 styles, persisted to localStorage key `flight-ops-style`; SSR-safe — state initializes to `DEFAULT_STYLE_ID` and the saved choice is read/applied only in a post-mount effect (no localStorage reads during render/hydration)
|
||||||
|
- `components/Map.tsx`: airports layer now survives style switches — `/api/airports` is fetched at most once per page life (promise cached in a ref; count set exactly once), and an idempotent `ensureAirportsLayer()` re-adds the source+layer if missing on EVERY `styledata` event (initial load AND after each `setStyle`, which wipes sources/layers not in the new style); the click→popup handler is re-wired with off+on using a stable reference since MapLibre layer-scoped listeners survive setStyle and would otherwise duplicate
|
||||||
|
- `Dockerfile`: runtime stage now copies `public/` from the build stage (`COPY --from=build /app/public ./public`) so `next start` serves the self-hosted assets; `.dockerignore` already lets public/ through
|
||||||
|
- Tests: new unit suite `lib/mapStyles.test.ts` (registry shape, fiord default, rewriteStyle against an inline OFM fixture — local glyphs/sprite, remote tiles preserved, attribution added/preserved, input not mutated); `e2e/map.spec.ts`: openfreemap mock simplified to abort ALL requests from that host (only tiles remain there), new test asserting the switcher has 5 options with Fiord default, switching changes the applied style's layer set AND the airports layer re-renders after the switch + exactly one popup on marker click (guards the setStyle-wipes-layers and duplicate-listener regressions), plus a reload-persistence test for the localStorage restore path
|
||||||
|
- Verified: unit suite 16/16; `tsc --noEmit` clean; e2e suite 6/6 green against the rebuilt compose instance (all styles/fonts/sprites served locally, fiord default, switch + persistence verified in a real browser)
|
||||||
|
|
||||||
## v0.3.1 — 2026-09-17 06:35
|
## v0.3.1 — 2026-09-17 06:35
|
||||||
- Header now shows the app version between the "Flight Ops" title and the airport count (`<small>v{version}</small>` in `components/Map.tsx`), sourced dynamically from `package.json` via a JSON import — future version bumps show up automatically with no code change; subtle 11px muted style added to `app/globals.css`
|
- Header now shows the app version between the "Flight Ops" title and the airport count (`<small>v{version}</small>` in `components/Map.tsx`), sourced dynamically from `package.json` via a JSON import — future version bumps show up automatically with no code change; subtle 11px muted style added to `app/globals.css`
|
||||||
- Tests: new unit suite `components/version.test.ts` (valid semver in package.json + guard that the header sources the version from package.json rather than hardcoding it); `e2e/map.spec.ts`: new test asserting the header shows `v{version}` with the expected value read from `package.json` at test time, so it stays in sync automatically
|
- Tests: new unit suite `components/version.test.ts` (valid semver in package.json + guard that the header sources the version from package.json rather than hardcoding it); `e2e/map.spec.ts`: new test asserting the header shows `v{version}` with the expected value read from `package.json` at test time, so it stays in sync automatically
|
||||||
|
|||||||
@@ -30,5 +30,7 @@ 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.
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ body {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Style switcher — kept subtle, like the version tag. */
|
||||||
|
.style-switch {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: rgba(30, 41, 59, 0.9);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-switch option {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
[data-testid='airport-count'] {
|
[data-testid='airport-count'] {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
|
|||||||
+94
-34
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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';
|
||||||
|
|
||||||
const EUROPE_CENTER: [number, number] = [10, 52];
|
const EUROPE_CENTER: [number, number] = [10, 52];
|
||||||
const INITIAL_ZOOM = 4;
|
const INITIAL_ZOOM = 4;
|
||||||
@@ -11,7 +12,9 @@ const INITIAL_ZOOM = 4;
|
|||||||
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
|
// European latitudes (~47 m/px at z11, 52°N); street-level zoom has no gameplay value.
|
||||||
export const MIN_ZOOM = 3;
|
export const MIN_ZOOM = 3;
|
||||||
export const MAX_ZOOM = 11;
|
export const MAX_ZOOM = 11;
|
||||||
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
|
||||||
|
const AIRPORTS_SOURCE_ID = 'airports';
|
||||||
|
const AIRPORTS_LAYER_ID = 'airports-circles';
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -19,7 +22,12 @@ function escapeHtml(s: string): string {
|
|||||||
|
|
||||||
export default function Map() {
|
export default function Map() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
// /api/airports is fetched at most once per page life: every styledata event
|
||||||
|
// (initial load AND after each setStyle) reuses this promise.
|
||||||
|
const airportsPromiseRef = useRef<Promise<{ features: unknown[] }> | null>(null);
|
||||||
const [airportCount, setAirportCount] = useState<number | null>(null);
|
const [airportCount, setAirportCount] = useState<number | null>(null);
|
||||||
|
const [styleId, setStyleId] = useState<string>(DEFAULT_STYLE_ID);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -30,33 +38,62 @@ export default function Map() {
|
|||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: OPENFREEMAP_STYLE,
|
style: styleUrl(DEFAULT_STYLE_ID), // self-hosted style (public/styles/)
|
||||||
center: EUROPE_CENTER,
|
center: EUROPE_CENTER,
|
||||||
zoom: INITIAL_ZOOM,
|
zoom: INITIAL_ZOOM,
|
||||||
minZoom: MIN_ZOOM,
|
minZoom: MIN_ZOOM,
|
||||||
maxZoom: MAX_ZOOM,
|
maxZoom: MAX_ZOOM,
|
||||||
});
|
});
|
||||||
|
mapRef.current = map;
|
||||||
// Dev/e2e hook so tests can project coordinates to screen space.
|
// Dev/e2e hook so tests can project coordinates to screen space.
|
||||||
(window as unknown as Record<string, unknown>).__flightMap = map;
|
(window as unknown as Record<string, unknown>).__flightMap = map;
|
||||||
|
|
||||||
// Airports are GeoJSON, so they don't depend on basemap tiles: initialize as soon
|
const onAirportClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
// as the style is applied ('styledata') rather than waiting for 'load', which also
|
const f = e.features?.[0];
|
||||||
// requires all tile sources to finish.
|
if (!f) return;
|
||||||
let initialized = false;
|
const p = f.properties as { name: string; icao: string };
|
||||||
const init = async () => {
|
new maplibregl.Popup({ offset: 10 })
|
||||||
if (initialized) return;
|
.setLngLat(f.geometry.coordinates as [number, number])
|
||||||
initialized = true;
|
.setHTML(
|
||||||
try {
|
`<div data-testid="airport-popup"><strong>${escapeHtml(p.name)}</strong><br/>${escapeHtml(p.icao)}</div>`,
|
||||||
const res = await fetch('/api/airports');
|
)
|
||||||
if (!res.ok) return;
|
.addTo(map);
|
||||||
const data = (await res.json()) as { features: unknown[] };
|
};
|
||||||
setAirportCount(data.features.length);
|
|
||||||
|
|
||||||
map.addSource('airports', { type: 'geojson', data });
|
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({
|
map.addLayer({
|
||||||
id: 'airports-circles',
|
id: AIRPORTS_LAYER_ID,
|
||||||
type: 'circle',
|
type: 'circle',
|
||||||
source: 'airports',
|
source: AIRPORTS_SOURCE_ID,
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': 3,
|
'circle-radius': 3,
|
||||||
'circle-color': '#e11d48',
|
'circle-color': '#e11d48',
|
||||||
@@ -64,37 +101,60 @@ export default function Map() {
|
|||||||
'circle-stroke-color': '#ffffff',
|
'circle-stroke-color': '#ffffff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on('click', 'airports-circles', (e) => {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('failed to initialize map layers', err);
|
|
||||||
}
|
}
|
||||||
|
// 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 init();
|
if (map.isStyleLoaded()) void ensureAirportsLayer();
|
||||||
else map.on('styledata', () => void init());
|
else map.on('styledata', () => void ensureAirportsLayer());
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
delete (window as unknown as Record<string, unknown>).__flightMap;
|
delete (window as unknown as Record<string, unknown>).__flightMap;
|
||||||
|
mapRef.current = null;
|
||||||
map.remove();
|
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 id = e.target.value;
|
||||||
|
if (!isStyleId(id)) return;
|
||||||
|
setStyleId(id);
|
||||||
|
window.localStorage.setItem(STYLE_STORAGE_KEY, id);
|
||||||
|
mapRef.current?.setStyle(styleUrl(id));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-wrap">
|
<div className="map-wrap">
|
||||||
<header data-testid="app-header" className="app-header">
|
<header data-testid="app-header" className="app-header">
|
||||||
<h1>Flight Ops</h1>
|
<h1>Flight Ops</h1>
|
||||||
<small>v{pkg.version}</small>
|
<small>v{pkg.version}</small>
|
||||||
|
<select
|
||||||
|
data-testid="style-switch"
|
||||||
|
className="style-switch"
|
||||||
|
aria-label="Map style"
|
||||||
|
value={styleId}
|
||||||
|
onChange={onStyleChange}
|
||||||
|
>
|
||||||
|
{STYLES.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<span data-testid="airport-count">
|
<span data-testid="airport-count">
|
||||||
{airportCount === null ? 'loading airports…' : `${airportCount} EU airports`}
|
{airportCount === null ? 'loading airports…' : `${airportCount} EU airports`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+77
-7
@@ -72,13 +72,11 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
|
test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
|
||||||
// Zoom clamping is pure camera behavior, so block basemap tile fetches (the
|
// Zoom clamping is pure camera behavior, so block basemap tile fetches: it keeps
|
||||||
// style JSON still loads): it keeps the headless main thread idle and makes the
|
// the headless main thread idle and makes the test fast and independent of
|
||||||
// test fast and independent of tile-server latency.
|
// tile-server latency. Styles/fonts/sprites are self-hosted now — anything from
|
||||||
await page.route('**://*.openfreemap.org/**', (route) => {
|
// openfreemap.org is a tile request, so abort all of them.
|
||||||
if (route.request().url().includes('/styles/')) return route.continue();
|
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
|
||||||
return route.abort();
|
|
||||||
});
|
|
||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
|
|
||||||
const getZoom = () => page.evaluate(() => (window as any).__flightMap?.getZoom() ?? -1);
|
const getZoom = () => page.evaluate(() => (window as any).__flightMap?.getZoom() ?? -1);
|
||||||
@@ -144,3 +142,75 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
|
|||||||
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
|
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
|
||||||
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
|
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('style switcher defaults to Fiord, switches styles, and keeps the airports layer', async ({ page }) => {
|
||||||
|
// Styles/fonts/sprites are self-hosted; only tiles come from openfreemap — abort
|
||||||
|
// them for speed (the GeoJSON airport layer renders without basemap tiles).
|
||||||
|
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
|
||||||
|
const select = page.getByTestId('style-switch');
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
await expect(select.locator('option')).toHaveCount(5);
|
||||||
|
// Fiord is the default style.
|
||||||
|
await expect(select).toHaveValue('fiord');
|
||||||
|
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
const renderedCircles = () =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as any).__flightMap?.queryRenderedFeatures(undefined, {
|
||||||
|
layers: ['airports-circles'],
|
||||||
|
})?.length ?? 0,
|
||||||
|
);
|
||||||
|
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const layerIds = () =>
|
||||||
|
page.evaluate(() => (window as any).__flightMap?.getStyle()?.layers?.map((l: { id: string }) => l.id) ?? []);
|
||||||
|
const before = await layerIds();
|
||||||
|
expect(before.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Switch to Dark.
|
||||||
|
await select.selectOption('dark');
|
||||||
|
await expect(select).toHaveValue('dark');
|
||||||
|
|
||||||
|
// The applied style must actually change (each OFM style has a different layer set).
|
||||||
|
await expect.poll(layerIds, { timeout: 15_000 }).not.toEqual(before);
|
||||||
|
|
||||||
|
// Regression guard for the setStyle-wipes-layers bug: map.setStyle() removes every
|
||||||
|
// source/layer not in the new style, so the airports layer must be re-established —
|
||||||
|
// count still shown and markers rendered again.
|
||||||
|
await expect(page.getByTestId('airport-count')).toContainText(/EU airports/);
|
||||||
|
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Clicking a marker after the switch opens exactly one popup (the click handler is
|
||||||
|
// re-wired idempotently — no duplicate listeners).
|
||||||
|
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 popups = page.getByTestId('airport-popup');
|
||||||
|
await expect(popups.first()).toBeVisible();
|
||||||
|
await expect(popups).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style choice persists across reloads via localStorage', async ({ page }) => {
|
||||||
|
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
|
||||||
|
const select = page.getByTestId('style-switch');
|
||||||
|
await expect(select).toHaveValue('fiord');
|
||||||
|
|
||||||
|
await select.selectOption('positron');
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem('flight-ops-style'));
|
||||||
|
expect(stored).toBe('positron');
|
||||||
|
|
||||||
|
// Reload: the persisted choice is restored after mount (SSR-safe path — server and
|
||||||
|
// first paint use the default, then the effect applies the saved style).
|
||||||
|
await page.reload();
|
||||||
|
await expect(select).toHaveValue('positron');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DEFAULT_STYLE_ID, STYLES, isStyleId, rewriteStyle, styleUrl } from './mapStyles';
|
||||||
|
|
||||||
|
// Mirrors the real OFM style JSONs (verified against tiles.openfreemap.org): same
|
||||||
|
// glyphs/sprite values and source shapes, no attribution anywhere.
|
||||||
|
const RAW_OFM_STYLE = {
|
||||||
|
version: 8,
|
||||||
|
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||||
|
sprite: 'https://tiles.openfreemap.org/sprites/ofm_f384/ofm',
|
||||||
|
sources: {
|
||||||
|
openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
|
||||||
|
ne2_shaded: {
|
||||||
|
maxzoom: 6,
|
||||||
|
tileSize: 256,
|
||||||
|
tiles: ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'],
|
||||||
|
type: 'raster',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('STYLES registry', () => {
|
||||||
|
it('lists exactly the five OFM styles', () => {
|
||||||
|
expect(STYLES.map((s) => s.id)).toEqual(['liberty', 'bright', 'positron', 'dark', 'fiord']);
|
||||||
|
for (const s of STYLES) expect(s.label.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to fiord', () => {
|
||||||
|
expect(DEFAULT_STYLE_ID).toBe('fiord');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isStyleId accepts only registered ids and styleUrl points at local files', () => {
|
||||||
|
for (const s of STYLES) {
|
||||||
|
expect(isStyleId(s.id)).toBe(true);
|
||||||
|
expect(styleUrl(s.id)).toBe(`/styles/${s.id}.json`);
|
||||||
|
}
|
||||||
|
expect(isStyleId('nope')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rewriteStyle', () => {
|
||||||
|
it('rewrites glyphs and sprite to local paths, leaves tiles remote', () => {
|
||||||
|
const out = rewriteStyle(RAW_OFM_STYLE) as typeof RAW_OFM_STYLE;
|
||||||
|
|
||||||
|
expect(out.glyphs).toBe('/fonts/{fontstack}/{range}.pbf');
|
||||||
|
expect(out.sprite).toBe('/sprites/ofm');
|
||||||
|
|
||||||
|
// Vector + raster tile urls must stay remote (tiles are not self-hosted yet).
|
||||||
|
const vector = out.sources.openmaptiles as { url: string };
|
||||||
|
expect(vector.url).toContain('tiles.openfreemap.org/planet');
|
||||||
|
const raster = out.sources.ne2_shaded as { tiles: string[] };
|
||||||
|
expect(raster.tiles[0]).toContain('tiles.openfreemap.org/natural_earth/ne2sr/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds OSM attribution to every source lacking one, without touching others', () => {
|
||||||
|
const out = rewriteStyle(RAW_OFM_STYLE) as typeof RAW_OFM_STYLE;
|
||||||
|
for (const src of Object.values(out.sources) as Array<Record<string, unknown>>) {
|
||||||
|
expect(src.attribution).toContain('OpenStreetMap');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that already has an attribution keeps it.
|
||||||
|
const withAttr = {
|
||||||
|
...RAW_OFM_STYLE,
|
||||||
|
sources: { a: { type: 'vector', url: 'x', attribution: 'custom' } },
|
||||||
|
};
|
||||||
|
const out2 = rewriteStyle(withAttr) as { sources: Record<string, { attribution?: string }> };
|
||||||
|
expect(out2.sources.a.attribution).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns non-objects unchanged and does not mutate the input', () => {
|
||||||
|
expect(rewriteStyle(null)).toBeNull();
|
||||||
|
expect(rewriteStyle('nope')).toBe('nope');
|
||||||
|
|
||||||
|
const before = JSON.stringify(RAW_OFM_STYLE);
|
||||||
|
rewriteStyle(RAW_OFM_STYLE);
|
||||||
|
expect(JSON.stringify(RAW_OFM_STYLE)).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Map style registry + rewriting of OpenFreeMap styles for self-hosted assets.
|
||||||
|
// Styles, fonts and sprites are served locally from /public (populated by
|
||||||
|
// scripts/download-map-assets.mjs); only vector tiles (/planet) and the ne2_shaded
|
||||||
|
// raster tiles stay remote on tiles.openfreemap.org.
|
||||||
|
|
||||||
|
export interface MapStyle {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STYLES: MapStyle[] = [
|
||||||
|
{ id: 'liberty', label: 'Liberty' },
|
||||||
|
{ id: 'bright', label: 'Bright' },
|
||||||
|
{ id: 'positron', label: 'Positron' },
|
||||||
|
{ id: 'dark', label: 'Dark' },
|
||||||
|
{ id: 'fiord', label: 'Fiord' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_STYLE_ID = 'fiord';
|
||||||
|
|
||||||
|
// localStorage key for the persisted style choice.
|
||||||
|
export const STYLE_STORAGE_KEY = 'flight-ops-style';
|
||||||
|
|
||||||
|
const OFM_FONTS_PREFIX = 'https://tiles.openfreemap.org/fonts/';
|
||||||
|
const OFM_SPRITE_URL = 'https://tiles.openfreemap.org/sprites/ofm_f384/ofm';
|
||||||
|
// Sprite files are extracted flat into public/sprites/, so the sprite base URL
|
||||||
|
// (MapLibre appends .json / @2x.png) points straight at them.
|
||||||
|
export const LOCAL_SPRITE_URL = '/sprites/ofm';
|
||||||
|
|
||||||
|
// OFM styles ship no attribution; ODbL requires visible OSM credit.
|
||||||
|
export const ATTRIBUTION = '© OpenStreetMap contributors © OpenFreeMap';
|
||||||
|
|
||||||
|
export function isStyleId(id: string): boolean {
|
||||||
|
return STYLES.some((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function styleUrl(id: string): string {
|
||||||
|
return `/styles/${id}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source = Record<string, unknown>;
|
||||||
|
|
||||||
|
// Takes a raw OFM style object and returns a new one with glyphs/sprite pointed at the
|
||||||
|
// self-hosted copies under /fonts and /sprites. Only those two fields are rewritten —
|
||||||
|
// the vector tile url (/planet) and raster tiles (/natural_earth/...) stay remote.
|
||||||
|
// Sources without an attribution get one added (ODbL requires visible OSM credit).
|
||||||
|
export function rewriteStyle(raw: unknown): unknown {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return raw;
|
||||||
|
const style = { ...(raw as Record<string, unknown>) };
|
||||||
|
|
||||||
|
const glyphs = style.glyphs;
|
||||||
|
if (typeof glyphs === 'string' && glyphs.startsWith(OFM_FONTS_PREFIX)) {
|
||||||
|
style.glyphs = '/fonts/' + glyphs.slice(OFM_FONTS_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.sprite === OFM_SPRITE_URL) {
|
||||||
|
style.sprite = LOCAL_SPRITE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = style.sources;
|
||||||
|
if (sources && typeof sources === 'object') {
|
||||||
|
const next: Record<string, unknown> = { ...(sources as Source) };
|
||||||
|
for (const [id, src] of Object.entries(next)) {
|
||||||
|
if (src && typeof src === 'object' && !('attribution' in (src as Source))) {
|
||||||
|
next[id] = { ...(src as Source), attribution: ATTRIBUTION };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style.sources = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
´
|
||||||
|
¤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.
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
´
|
||||||
|
¤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.
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