mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- One place-name key for server and tiles, so "St. Paul" and "Saint Paul"
agree in Nominatim name checks.
- Tile street names: a lone letter is a direction only beside another name
word ("E Street" is not "East Street"), and a trailing quadrant ("E St NW")
may go unsaid in the ask.
- A building cut at the tile edge that is nearer the point than any whole
one leaves the point instead of a farther neighbour standing in; an edge
without a tile buffer counts as a cut, so a cut area never answers grounds.
- A batch building install re-judges only results that installed; a late
timeout during caption conversion keeps the pending point drawn; a failed
plain recaption keeps the old mark addressable.
- An interrupted Nominatim dispatch is reclaimed by the next lock owner ten
minutes after the start its marker names; node:sqlite loads on first use
without its experimental warning.
- PHOTON_URL accepts only https, which the page's connect-src allows.
- Shared OpenFreeMap tiles decode each layer when a consumer first selects
it; outline selection reuses the shared ring area and simplification, and
ground probes the shared ring centroid; the grounds word list lives once.
389 lines
12 KiB
JavaScript
389 lines
12 KiB
JavaScript
import { PbfReader } from 'pbf';
|
|
import { VectorTile } from '@mapbox/vector-tile';
|
|
import {
|
|
createSharedDecodedTileCache,
|
|
createVectorTileSource,
|
|
} from './vectorTiles.js';
|
|
import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js';
|
|
import { tileToBBox } from '../data/tomtomTiles.js';
|
|
// Kept English in data; the map card and Contacts translate it.
|
|
import { UNNAMED_MILITARY_AREA } from '../data/installationNames.js';
|
|
|
|
/** Name of a military polygon the names pack has not named. */
|
|
export { UNNAMED_MILITARY_AREA };
|
|
|
|
const ROAD_TYPES = Object.freeze({
|
|
motorway: 'motorway',
|
|
trunk: 'trunk',
|
|
primary: 'primary',
|
|
secondary: 'secondary',
|
|
tertiary: 'tertiary',
|
|
minor: 'residential',
|
|
});
|
|
|
|
const SHARED_DECODED_TILES = createSharedDecodedTileCache({
|
|
maxEntries: 192,
|
|
maxCacheBytes: 32 * 1024 * 1024,
|
|
});
|
|
const TILE_PROJECTIONS = new Map();
|
|
const MAX_PROJECTED_NUMBERS = 500_000;
|
|
|
|
/** Conservative, deterministic retained-size estimate with a hard geometry cap. */
|
|
export function estimateOpenFreeMapProjectionBytes(value) {
|
|
const seen = new Set();
|
|
const stack = [value];
|
|
let bytes = 0;
|
|
let numbers = 0;
|
|
while (stack.length) {
|
|
const item = stack.pop();
|
|
if (item == null) continue;
|
|
if (typeof item === 'number') {
|
|
numbers += 1;
|
|
if (numbers > MAX_PROJECTED_NUMBERS)
|
|
throw new Error('Vector tile projection coordinate limit exceeded');
|
|
bytes += 8;
|
|
} else if (typeof item === 'string') {
|
|
bytes += 16 + item.length * 2;
|
|
} else if (typeof item === 'boolean') {
|
|
bytes += 4;
|
|
} else if (typeof item === 'object') {
|
|
if (seen.has(item)) continue;
|
|
seen.add(item);
|
|
if (Array.isArray(item)) {
|
|
bytes += 24 + item.length * 8;
|
|
for (let i = 0; i < item.length; i += 1) stack.push(item[i]);
|
|
} else {
|
|
const entries = Object.entries(item);
|
|
bytes += 48 + entries.length * 16;
|
|
for (const [key, child] of entries) {
|
|
bytes += key.length * 2;
|
|
stack.push(child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/** Register one consumer view over the parsed OpenFreeMap tile. */
|
|
export function registerOpenFreeMapProjection(name, project) {
|
|
if (!name || typeof project !== 'function')
|
|
throw new TypeError('Invalid OpenFreeMap tile projection');
|
|
const current = TILE_PROJECTIONS.get(name);
|
|
if (current && current !== project)
|
|
throw new Error(`OpenFreeMap tile projection already registered: ${name}`);
|
|
TILE_PROJECTIONS.set(name, project);
|
|
}
|
|
|
|
/** Explicit test/runtime reset; individual consumers never evict each other. */
|
|
export function clearSharedOpenFreeMapTiles() {
|
|
SHARED_DECODED_TILES.clear();
|
|
}
|
|
|
|
/**
|
|
* Surface traffic uses public motor-road classes only. Untyped service ways
|
|
* include car parks and grounds access, so none are used as through traffic.
|
|
* OpenMapTiles folds private access into `no`. Tunnels cannot be placed on
|
|
* the surface above them; ramps/bridges remain valid only on a motor road.
|
|
*/
|
|
function isDrivableOpenMapRoad(properties = {}) {
|
|
return (
|
|
Object.hasOwn(ROAD_TYPES, properties.class) &&
|
|
!properties.subclass &&
|
|
!properties.service &&
|
|
![
|
|
'no',
|
|
'private',
|
|
'destination',
|
|
'customers',
|
|
'delivery',
|
|
'agricultural',
|
|
'forestry',
|
|
].includes(properties.access) &&
|
|
properties.brunnel !== 'tunnel' &&
|
|
!properties.indoor
|
|
);
|
|
}
|
|
|
|
/** Translate public motor roads; reverse negative one-way geometry. */
|
|
export function openMapRoad(coordinates, properties = {}) {
|
|
const type = ROAD_TYPES[properties.class];
|
|
if (!isDrivableOpenMapRoad(properties) || coordinates.length < 2) return null;
|
|
const reverse = Number(properties.oneway) === -1;
|
|
return {
|
|
coordinates: reverse ? coordinates.slice().reverse() : coordinates,
|
|
type,
|
|
drivable: true,
|
|
roadClass: properties.class,
|
|
roadProperties: { ...properties },
|
|
oneway: reverse || Number(properties.oneway) === 1 ? 1 : 0,
|
|
ramp: properties.ramp === 1,
|
|
brunnel: properties.brunnel || null,
|
|
};
|
|
}
|
|
|
|
/** Area-weighted centroid in tile-scale longitude/latitude coordinates. */
|
|
export function polygonCentroid(ring) {
|
|
if (!ring?.length) return null;
|
|
const [ox, oy] = ring[0];
|
|
let area = 0,
|
|
x = 0,
|
|
y = 0;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const ax = ring[j][0] - ox,
|
|
ay = ring[j][1] - oy;
|
|
const bx = ring[i][0] - ox,
|
|
by = ring[i][1] - oy;
|
|
const cross = ax * by - bx * ay;
|
|
area += cross;
|
|
x += (ax + bx) * cross;
|
|
y += (ay + by) * cross;
|
|
}
|
|
return Math.abs(area) > 1e-15
|
|
? [ox + x / (3 * area), oy + y / (3 * area)]
|
|
: ring[0];
|
|
}
|
|
|
|
/** Clip a line to its tile core so buffered copies never animate duplicate dots. */
|
|
export function clipTileLine(coords, box) {
|
|
const lines = [];
|
|
let line = [];
|
|
for (let i = 1; i < coords.length; i++) {
|
|
const a = coords[i - 1],
|
|
b = coords[i];
|
|
const dx = b[0] - a[0],
|
|
dy = b[1] - a[1];
|
|
let lo = 0,
|
|
hi = 1;
|
|
const p = [-dx, dx, -dy, dy],
|
|
q = [
|
|
a[0] - box.west,
|
|
box.east - a[0],
|
|
a[1] - box.south,
|
|
box.north - a[1],
|
|
];
|
|
for (let k = 0; k < 4; k++) {
|
|
if (p[k] === 0) {
|
|
if (q[k] < 0) hi = -1;
|
|
} else if (p[k] < 0) lo = Math.max(lo, q[k] / p[k]);
|
|
else hi = Math.min(hi, q[k] / p[k]);
|
|
}
|
|
if (lo > hi) {
|
|
if (line.length > 1) lines.push(line);
|
|
line = [];
|
|
continue;
|
|
}
|
|
const start = [a[0] + lo * dx, a[1] + lo * dy],
|
|
end = [a[0] + hi * dx, a[1] + hi * dy];
|
|
const last = line.at(-1);
|
|
if (
|
|
!last ||
|
|
Math.abs(last[0] - start[0]) + Math.abs(last[1] - start[1]) > 1e-10
|
|
) {
|
|
if (line.length > 1) lines.push(line);
|
|
line = [start];
|
|
}
|
|
line.push(end);
|
|
}
|
|
if (line.length > 1) lines.push(line);
|
|
return lines.filter((points) => {
|
|
let metres = 0;
|
|
for (let i = 1; i < points.length; i++)
|
|
metres +=
|
|
Math.hypot(
|
|
(points[i][0] - points[i - 1][0]) *
|
|
Math.cos((points[i][1] * Math.PI) / 180),
|
|
points[i][1] - points[i - 1][1],
|
|
) * 111320;
|
|
return metres >= 12;
|
|
});
|
|
}
|
|
|
|
/** Decode road lines and unnamed military polygons from an OpenMapTiles tile. */
|
|
export function decodeOpenFreeMapTile(bytes, z, x, y) {
|
|
return decodeLayers(bytes, z, x, y, ['transportation', 'landuse']);
|
|
}
|
|
|
|
/** Decode only the unnamed military polygons; installations never use roads. */
|
|
export function decodeOpenFreeMapMilitaryTile(bytes, z, x, y) {
|
|
return decodeLayers(bytes, z, x, y, ['landuse']);
|
|
}
|
|
|
|
function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
|
|
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
|
|
const roads = [],
|
|
military = [];
|
|
const box = tileToBBox(z, x, y);
|
|
for (const name of layerNames) {
|
|
const layer = tile.layers[name];
|
|
if (!layer) continue;
|
|
if (layer.length > 40_000)
|
|
throw new Error('Vector tile feature limit exceeded');
|
|
for (let i = 0; i < layer.length; i++) {
|
|
const feature = layer.feature(i),
|
|
props = feature.properties;
|
|
if (
|
|
name === 'transportation'
|
|
? !isDrivableOpenMapRoad(props)
|
|
: props.class !== 'military'
|
|
)
|
|
continue;
|
|
const geometry = feature.toGeoJSON(x, y, z).geometry;
|
|
if (name === 'transportation') {
|
|
const lines =
|
|
geometry.type === 'LineString'
|
|
? [geometry.coordinates]
|
|
: geometry.type === 'MultiLineString'
|
|
? geometry.coordinates
|
|
: [];
|
|
for (const coords of lines)
|
|
for (const clipped of clipTileLine(coords, box))
|
|
roads.push(openMapRoad(clipped, props));
|
|
} else {
|
|
const polygons =
|
|
geometry.type === 'Polygon'
|
|
? [geometry.coordinates]
|
|
: geometry.type === 'MultiPolygon'
|
|
? geometry.coordinates
|
|
: [];
|
|
for (let p = 0; p < polygons.length; p++) {
|
|
const rings = polygons[p]
|
|
.map((r) => clipTileRing(r, box))
|
|
.filter((r) => r.length >= 4);
|
|
const ring = rings[0],
|
|
centroid = polygonCentroid(ring);
|
|
if (!centroid || ring.length < 4) continue;
|
|
const [longitude, latitude] = centroid;
|
|
military.push({
|
|
id: `ofm:${z}/${x}/${y}:${feature.id ?? i}:${p}`,
|
|
kind: 'installation',
|
|
class: 'military_land',
|
|
name: UNNAMED_MILITARY_AREA,
|
|
latitude,
|
|
longitude,
|
|
footprint: ring,
|
|
rings,
|
|
featureKey:
|
|
feature.id == null
|
|
? `tile:${z}/${x}/${y}:${i}`
|
|
: String(feature.id),
|
|
tileEpsilon: (box.east - box.west) / feature.extent,
|
|
tileBounds: box,
|
|
tileZoom: z,
|
|
outlineLines: rings.flatMap((r) => militaryOutlineLines(r, box)),
|
|
validation: 'unreviewed',
|
|
sources: [{ name: 'OpenStreetMap', id: `tile:${z}/${x}/${y}` }],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { roads, military };
|
|
}
|
|
|
|
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
|
|
const tile = new VectorTile(new PbfReader(bytes));
|
|
// Each layer is decoded when a consumer first selects it, so installations
|
|
// never pay for roads, nor outlines for either.
|
|
let roads = null;
|
|
let military = null;
|
|
const projections = new Map();
|
|
let baseBytes = 0;
|
|
let projectionBytes = 0;
|
|
let resizeCacheEntry = null;
|
|
const project = (name) => {
|
|
if (projections.has(name)) return projections.get(name);
|
|
const projector = TILE_PROJECTIONS.get(name);
|
|
if (!projector)
|
|
throw new Error(`Unknown OpenFreeMap tile projection: ${name}`);
|
|
const value = projector(bytes, z, x, y, tile);
|
|
const retainedBytes = estimateOpenFreeMapProjectionBytes(value);
|
|
projections.set(name, value);
|
|
projectionBytes += retainedBytes;
|
|
resizeCacheEntry?.(baseBytes + projectionBytes);
|
|
return value;
|
|
};
|
|
|
|
return {
|
|
get roads() {
|
|
return (roads ??= decodeLayers(
|
|
bytes,
|
|
z,
|
|
x,
|
|
y,
|
|
['transportation'],
|
|
tile,
|
|
).roads);
|
|
},
|
|
get military() {
|
|
return (military ??= decodeLayers(
|
|
bytes,
|
|
z,
|
|
x,
|
|
y,
|
|
['landuse'],
|
|
tile,
|
|
).military);
|
|
},
|
|
project,
|
|
bindSharedCacheEntry(resize, initialBytes) {
|
|
resizeCacheEntry = resize;
|
|
baseBytes = initialBytes;
|
|
resizeCacheEntry(baseBytes + projectionBytes);
|
|
},
|
|
releaseSharedCacheEntry() {
|
|
projections.clear();
|
|
baseBytes = 0;
|
|
projectionBytes = 0;
|
|
resizeCacheEntry = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Construct an immutable-version road/military tile source without starting I/O.
|
|
*
|
|
* `allowedOrigin` is deliberately not set here: `createVectorTileSource`
|
|
* derives it from whichever `tileJsonUrl` is in effect, so a caller that
|
|
* repoints only `tileJsonUrl` gets tiles from that host or a named error —
|
|
* never a silent fall back to the default host (#933). Passing
|
|
* `allowedOrigin` explicitly still overrides both.
|
|
*/
|
|
export function createOpenFreeMapSource(options = {}) {
|
|
const {
|
|
projection = null,
|
|
decode,
|
|
sharedDecodedCache,
|
|
selectDecoded,
|
|
...sourceOptions
|
|
} = options;
|
|
const shared = !decode;
|
|
// Shared consumers hold only their slice of each tile. The shared decode
|
|
// (32 MiB) plus traffic and installations (12 MiB each) and outlines
|
|
// (8 MiB) are charged 64 MiB together, the budget of the single OpenFreeMap
|
|
// source traffic and installations shared before; charges are estimates.
|
|
return createVectorTileSource({
|
|
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
|
decode: shared
|
|
? decodeSharedOpenFreeMapTile
|
|
: decode || decodeOpenFreeMapTile,
|
|
...(shared
|
|
? {
|
|
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
|
|
selectedSize: estimateOpenFreeMapProjectionBytes,
|
|
selectDecoded:
|
|
selectDecoded ||
|
|
(projection
|
|
? (value) => value.project(projection)
|
|
: ({ roads, military }) => ({ roads, military })),
|
|
}
|
|
: {
|
|
...(sharedDecodedCache ? { sharedDecodedCache } : {}),
|
|
...(selectDecoded ? { selectDecoded } : {}),
|
|
}),
|
|
maxEntries: 192,
|
|
maxCacheBytes: (shared ? 12 : 64) * 1024 * 1024,
|
|
...sourceOptions,
|
|
});
|
|
}
|