/** * Builds the border crossings the full-screen map shows, from what * scripts/fetch-border-crossings.mjs read of OpenStreetMap. * * npm run crossings:build [folder of tiles] * * Writes src/features/passport/data/border-crossings.generated.ts. Commit it. * * What is kept is each post that is on a land border - within as far of the * atlas's finer line as OpenStreetMap's own posts are measured to lie from it * (pin-country.ts PIN_ASK_WITHIN_KM) - with the two lands the line there runs * between, so a post in a port, an airport or the middle of a country (a * customs shed on a road inland) is left out, and so is one tagged as closed, * private or military. Posts of one crossing - a lane for each direction - * are one. What each says of who may cross and when is kept as OpenStreetMap * wrote it, short, for the card. * * Then each mark gets the zoom it shows from. They are ranked - a name, hours, * a wikipedia page, the modes listed and how many lanes it has count - each * border's best before any border's second, and each, best first, shows from * the least zoom at which it has CROSSING_SPACING points to itself among those * already showing. So a continent shows its main crossings as it opens, one on * each border it has room for, and the map shows more as it closes in, and no two marks are * ever nearer than a finger. A named crossing that never has the room, even at * the map's closest zoom, is not lost: it is written on the card of the mark * that stands in its way, which the finger can then ask about. */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { CROSSING_FROM, CROSSING_MODES, CROSSING_SPACING } from '../src/features/passport/data/border-crossings.ts'; import { bordersNear, readBorderLines, sideOfBorder } from '../src/features/passport/data/border-lines.ts'; import { PIN_ASK_WITHIN_KM } from '../src/features/passport/data/pin-country.ts'; import { BORDER_GRID, BORDER_LANDS, BORDER_LINES } from '../src/features/passport/data/world-border-lines.generated.ts'; import { CROSSINGS_CACHE } from './fetch-border-crossings.mjs'; export const CROSSINGS_PATH = fileURLToPath(new URL('../src/features/passport/data/border-crossings.generated.ts', import.meta.url)); /** The closest the full-screen map zooms, in points to a degree (world-map-plate.ts MAX_POINTS_PER_UNIT). */ const CLOSEST = Number(readFileSync(new URL('../src/features/passport/data/world-map-plate.ts', import.meta.url), 'utf8').match(/export const MAX_POINTS_PER_UNIT = (\d+);/)[1]); /** Posts of one crossing are within this of each other, in km. */ const SAME_CROSSING_KM = 1.2; /** * Where a post is this near two borders at once - within a few kilometres of * where three lands meet - the line cannot say which of the two it is on, and * the post is put between all the lands. Kapıkule is 2.3 km from where * Bulgaria, Greece and Türkiye meet, and the nearest line is Greece's. */ const AMBIGUOUS_KM = 3; /** The most named neighbours a card carries. */ const MOST_NEARBY = 6; const yes = (value) => value === 'yes' || value === 'designated' || value === 'permissive'; const no = (value) => value === 'no'; /** What a post says of who may cross, as bits (CROSSING_MODES). */ function modesOf(tags) { let bits = 0; if (yes(tags.foot)) bits |= CROSSING_MODES.foot; if (yes(tags.bicycle)) bits |= CROSSING_MODES.bicycle; if (yes(tags.motorcar) || yes(tags.motorcycle) || yes(tags.motor_vehicle)) bits |= CROSSING_MODES.car; if (yes(tags.bus) || yes(tags.coach)) bits |= CROSSING_MODES.bus; if (yes(tags.hgv)) bits |= CROSSING_MODES.truck; if (yes(tags.vehicle)) bits |= CROSSING_MODES.car | CROSSING_MODES.bicycle; return bits; } const clean = (text, most) => text.replace(/\s+/g, ' ').trim().slice(0, most).trim(); /** The closing mark of each opening one; a straight quote closes itself. */ const CLOSERS = { '(': [')'], '[': [']'], '«': ['»'], '„': ['“', '”'], '“': ['”'], '"': ['"'] }; /** The text up to its first bracket or quote that is never closed, so no name is left with half of one. */ function closedOnly(text) { const open = []; for (const [index, mark] of [...text].entries()) { const top = open.at(-1); if (top && CLOSERS[top.mark].includes(mark)) open.pop(); else if (CLOSERS[mark]) open.push({ mark, index }); } return open.length > 0 ? [...text].slice(0, open[0].index).join('') : text; } /** * A name of at most `most` characters, cut where a word ends - never inside * one, as "Two Rivers - Entrance - Botsw" was - and with no bracket or quote * left open: what such a part was going to say is dropped whole. A name cut * anywhere else ends in an ellipsis, so it reads as cut and not as wrong. */ export function shortName(text, most) { const whole = text.replace(/\s+/g, ' ').trim(); const trimEnd = (part) => part.replace(/[\s,;:\-–—/&·.]+$/u, ''); let closed = trimEnd(closedOnly(whole)); // A trailing part in brackets - "(Automobile, International)" - is the first to go: what is left is still a whole name. while ([...closed].length > most && /\s*\([^()]*\)$/u.test(closed)) closed = trimEnd(closed.replace(/\s*\([^()]*\)$/u, '')); if ([...closed].length <= most) return closed; const room = [...closed].slice(0, most).join(''); // The last space at or before the limit: the cut falls between two words. const cut = /\s/.test([...closed][most] ?? ' ') ? room : room.slice(0, Math.max(0, room.lastIndexOf(' '))); const kept = trimEnd(closedOnly(trimEnd(cut))); if (kept.length === 0) return ''; // An open part dropped leaves a whole name; a cut anywhere else is said with an ellipsis, inside the limit. if (kept !== trimEnd(cut)) return kept; return [...kept].length < most ? `${kept}…` : shortName(kept, most - 1); } /** The name a traveler would read: English where OpenStreetMap has it. */ function nameOf(tags) { return shortName(tags['name:en'] ?? tags.int_name ?? tags.name ?? '', 60); } /** Hours as OpenStreetMap writes them, if they are short enough for a card. */ function hoursOf(tags) { const hours = clean(tags.opening_hours ?? '', 80); return hours.length <= 48 ? hours : ''; } /** Whether OpenStreetMap says the post is not one a traveler can use. */ function closed(tags) { const access = tags.access; if (access === 'no' || access === 'private' || access === 'military' || access === 'customers') return true; if (['off', 'closed'].includes((tags.opening_hours ?? '').trim().toLowerCase())) return true; return no(tags.foot) && no(tags.bicycle) && no(tags.motorcar) && no(tags.motor_vehicle) && no(tags.vehicle); } const score = (tags, row) => (row.name ? 3 : 0) + (row.hours ? 1 : 0) + Math.min(2, [...Object.values(CROSSING_MODES)].filter((bit) => (row.modes & bit) !== 0).length) + (tags.wikidata || tags.wikipedia ? 2 : 0) + (tags.operator ? 1 : 0); function kmBetween(a, b) { const dLat = (a.latitude - b.latitude) * 110.57; const dLon = (a.longitude - b.longitude) * 111.32 * Math.cos((((a.latitude + b.latitude) / 2) * Math.PI) / 180); return Math.hypot(dLat, dLon); } export function buildCrossings(elements) { const lines = readBorderLines(BORDER_LINES, BORDER_LANDS, BORDER_GRID); const every = new Set(BORDER_LANDS); const stats = { read: 0, closed: 0, awayFromBorders: 0, merged: 0, noRoom: 0 }; const seen = new Set(); const posts = []; for (const element of elements) { // A post mapped as an area - a customs yard - is where its centre is. const latitude = element.lat ?? element.center?.lat; const longitude = element.lon ?? element.center?.lon; const id = `${element.type}${element.id}`; if (seen.has(id) || typeof latitude !== 'number' || typeof longitude !== 'number') continue; seen.add(id); stats.read += 1; const tags = element.tags ?? {}; if (closed(tags)) { stats.closed += 1; continue; } const side = sideOfBorder(lines, latitude, longitude, PIN_ASK_WITHIN_KM, every); if (!side) { stats.awayFromBorders += 1; continue; } // Any other border of either land that is about as near: the lands it runs to are in the doubt. const lands = new Set([side.land, side.across]); for (const land of [side.land, side.across]) { for (const other of bordersNear(lines, latitude, longitude, land, Math.min(PIN_ASK_WITHIN_KM, side.km + AMBIGUOUS_KM))) { if (!other.pastEnd) lands.add(other.land); } } const between = [...lands].sort(); const row = { latitude: Number(latitude.toFixed(4)), longitude: Number(longitude.toFixed(4)), lands: between, modes: modesOf(tags), name: nameOf(tags), hours: hoursOf(tags) }; posts.push({ ...row, rank: score(tags, row) }); } // Posts of one crossing - a lane for each direction - are one: the one with most to say stands for them. posts.sort((a, b) => b.rank - a.rank || a.latitude - b.latitude || a.longitude - b.longitude); const crossings = []; const cells = new Map(); const cellOf = (latitude, longitude) => `${Math.floor(latitude / 0.1)}|${Math.floor(longitude / 0.1)}`; for (const post of posts) { const row = Math.floor(post.latitude / 0.1); const column = Math.floor(post.longitude / 0.1); let twin; for (let dr = -1; dr <= 1 && !twin; dr += 1) { for (let dc = -1; dc <= 1 && !twin; dc += 1) { for (const other of cells.get(`${row + dr}|${column + dc}`) ?? []) { if (other.lands.join('|') === post.lands.join('|') && kmBetween(other, post) <= SAME_CROSSING_KM) { twin = other; break; } } } } if (twin) { twin.modes |= post.modes; twin.lanes += 1; if (!twin.hours) twin.hours = post.hours; stats.merged += 1; continue; } post.lanes = 1; crossings.push(post); const key = cellOf(post.latitude, post.longitude); cells.set(key, [...(cells.get(key) ?? []), post]); } // A crossing of many lanes is a busy one: it counts for its lanes as well as for what it says of itself. for (const crossing of crossings) crossing.rank += crossing.lanes >= 6 ? 3 : crossing.lanes >= 3 ? 2 : crossing.lanes >= 2 ? 1 : 0; // Each border's own best crossing - between Spain and Portugal, Chad and Sudan - is placed before any border's // second: a continent shows a crossing on as many of its borders as there is room for before it shows two on one. const firstOfPair = new Map(); for (const crossing of crossings) { const pair = crossing.lands.join('|'); const best = firstOfPair.get(pair); if (!best || crossing.rank > best.rank || (crossing.rank === best.rank && (crossing.latitude < best.latitude || (crossing.latitude === best.latitude && crossing.longitude < best.longitude)))) firstOfPair.set(pair, crossing); } const leads = new Set(firstOfPair.values()); // The zoom each shows from, best first: when it has room among those already showing. crossings.sort((a, b) => Number(leads.has(b)) - Number(leads.has(a)) || b.rank - a.rank || a.latitude - b.latitude || a.longitude - b.longitude); const crowded = []; const grid = new Map(); const cell = 1; const showing = []; for (const crossing of crossings) { const x = crossing.longitude + 180; const y = 90 - crossing.latitude; let from = CROSSING_FROM; const reachDegrees = CROSSING_SPACING / CROSSING_FROM; const near = []; for (let cx = Math.floor((x - reachDegrees) / cell); cx <= Math.floor((x + reachDegrees) / cell); cx += 1) { for (let cy = Math.floor((y - reachDegrees) / cell); cy <= Math.floor((y + reachDegrees) / cell); cy += 1) near.push(...(grid.get(`${cx}|${cy}`) ?? [])); } // Raised until, once both show, it is CROSSING_SPACING from every one already placed: from the later of the two zooms. for (let settled = false; !settled; ) { settled = true; for (const other of near) { const distance = Math.hypot(other.x - x, other.y - y); // Not by a rounding error: SPACING / distance * distance may fall a hair short of SPACING, and the loop would never end. if (distance * Math.max(from, other.from) < CROSSING_SPACING - 1e-9) { from = CROSSING_SPACING / distance; settled = false; } } } if (from > CLOSEST) { stats.noRoom += 1; if (crossing.name) crowded.push({ crossing, x, y }); continue; } crossing.from = Math.ceil(from * 100) / 100; crossing.x = x; crossing.y = y; showing.push(crossing); const key = `${Math.floor(x / cell)}|${Math.floor(y / cell)}`; grid.set(key, [...(grid.get(key) ?? []), crossing]); } // A named crossing with no room is written on the card of the nearest mark, within the reach of one. const reach = CROSSING_SPACING / CLOSEST + 1e-9; for (const { crossing, x, y } of crowded) { let nearest; let best = Infinity; for (const other of showing) { const distance = Math.hypot(other.x - x, other.y - y); if (distance < best) { best = distance; nearest = other; } } if (!nearest || best > reach) continue; nearest.nearby = [...(nearest.nearby ?? []), { crossing, distance: best }]; } showing.sort((a, b) => a.from - b.from || b.rank - a.rank || a.latitude - b.latitude || a.longitude - b.longitude); // The named neighbours a card carries: the nearest, each name once. const chosen = new Map(); for (const crossing of showing) { const seenNames = new Set([crossing.name]); const picked = []; for (const { crossing: near } of [...(crossing.nearby ?? [])].sort((a, b) => a.distance - b.distance || b.crossing.rank - a.crossing.rank)) { if (seenNames.has(near.name)) continue; seenNames.add(near.name); picked.push(near); if (picked.length === MOST_NEARBY) break; } chosen.set(crossing, picked); } const pairs = [...new Set(showing.flatMap((crossing) => [crossing, ...chosen.get(crossing)]).map((crossing) => crossing.lands.join('|')))].sort(); const rows = showing.map((crossing) => [ crossing.latitude, crossing.longitude, crossing.from, pairs.indexOf(crossing.lands.join('|')), crossing.modes, crossing.name, crossing.hours, chosen.get(crossing).map((near) => [pairs.indexOf(near.lands.join('|')), near.name]), ]); return { rows, pairs, stats }; } export function generatedCrossingsSource({ rows, pairs }) { return `// Generated by scripts/build-border-crossings.mjs from OpenStreetMap's border posts (barrier=border_control), // © OpenStreetMap contributors, made available under the ODbL (https://www.openstreetmap.org/copyright). Do not edit. import type { CrossingRow } from './border-crossings.ts'; /** The two lands each crossing is between, "a|b" by the ids the map knows them by. */ export const BORDER_CROSSING_PAIRS: readonly string[] = ${JSON.stringify(pairs)}; /** Latitude, longitude, the zoom it shows from, its pair, the modes listed, its name, its hours and the named crossings with no room of their own near it: the earliest first. */ export const BORDER_CROSSING_ROWS: readonly CrossingRow[] = [ ${rows.map((row) => ` ${JSON.stringify(row)},`).join('\n')} ]; `; } if (process.argv[1] === fileURLToPath(import.meta.url)) { const folder = process.argv[2] ?? CROSSINGS_CACHE; const elements = readdirSync(folder) .filter((file) => file.endsWith('.json')) .flatMap((file) => JSON.parse(readFileSync(join(folder, file), 'utf8'))); const built = buildCrossings(elements); writeFileSync(CROSSINGS_PATH, generatedCrossingsSource(built)); console.log(JSON.stringify({ crossings: built.rows.length, pairs: built.pairs.length, ...built.stats })); }