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:
2026-09-17 08:24:58 +00:00
parent b011953087
commit 501c452377
789 changed files with 5513 additions and 42 deletions
+78
View File
@@ -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);
});
});
+72
View File
@@ -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;
}