/** * Does, once and here, the work the app used to do before its first screen. * * On launch the app parsed world-atlas (740 KB) and world-countries (565 KB, * twenty-four fields a country, translations into two dozen languages), * decoded the topology, and projected every country into SVG path data - * a megabyte and a half of it - before the passport could draw. Expo's Metro * does not inline requires, so every module a route imports is evaluated at * start whether or not its screen is ever opened. * * This writes the modules the app imports instead: * world-map-shapes.generated.ts the drawn world, already projected, its * borders where the United Nations draws * them (world-un-borders.ts) * world-countries-lite.generated.ts the 198 records and the fields the * app reads, and nothing else * world-countries-slim.generated.ts the same records' names, subregion * and centre: what the first screen * reads of them (slimCountries) * world-map-territories.generated.ts the land that is not one of the 197 * world-map-markers.generated.ts the countries too small to be seen on the * map, and where each one's marker goes * country-reach.generated.ts how far north and south each country's * mainland reaches, for the profile's facts * world-land-mask.generated.ts where the full-screen map has land, for * the hand that moves it (plate-land.ts) * * The polygons themselves (world-map-data.ts) are still decoded on the phone, * but only when something asks which country a coordinate is in. * * tests/world-data-generated.test.mjs rebuilds both from their sources and * fails if these files have drifted: after upgrading world-atlas or * world-countries, or editing the countries, run `npm run world:build`. */ import { writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import worldAtlas from 'world-atlas/countries-50m.json' with { type: 'json' }; import worldCountries from 'world-countries'; import { COUNTRIES, countryIsoCode } from '../src/features/passport/data/countries.ts'; import { mainlandReach } from '../src/features/passport/data/journey-facts.ts'; import { buildWorldLand } from '../src/features/passport/data/world-land.ts'; import { flagOfIsoCode, TERRITORIES } from '../src/features/passport/data/territories.ts'; import { buildCountryShapes, buildTerritoryShapes } from '../src/features/passport/data/world-map-paths.ts'; import { buildDetailShapes } from '../src/features/passport/data/world-map-detail-paths.ts'; import { FINE_CLIP } from '../src/features/passport/data/world-map-detail.ts'; import { encodeLand, markLand, rasterizeLand } from '../src/features/passport/data/plate-land.ts'; import { languagesFor } from './country-languages.mjs'; import { deriveSmallCountryMarkers, smallCountryMarkersSource } from './small-country-markers.mjs'; import { TERRITORY_NAMES } from './territory-names.mjs'; import { WORLD_FEATURES } from './world-features.mjs'; const out = (name) => fileURLToPath(new URL(`../src/features/passport/data/${name}`, import.meta.url)); /** The drawn world, exactly as the app would have projected it. */ export function generatedShapesSource() { const shapes = buildCountryShapes(WORLD_FEATURES.features); return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // Every Passport country projected into SVG path data, with the box its label // follows. See scripts/build-world-data.mjs for why this is not done on the phone. import type { CountryShape } from '@/features/passport/data/world-map-paths'; export const COUNTRY_SHAPES: readonly CountryShape[] = ${JSON.stringify(shapes)}; `; } /** * Every coast and border drawn ten times finer than the world's own paths: * a hundredth of a plate unit, not a tenth, cut to the frames the map draws * (world-map-detail.ts). The map loads them when the full-screen viewer * opens, for the frames it looks at closely and for any zoom past the plate. */ export function generatedFineSource() { return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // Every country and territory as SVG path data to a hundredth of a plate unit // (the world's own, in world-map-shapes.generated.ts, is a tenth), for the // closest views of the full-screen map. See world-map-detail.ts for what it is // cut to, and scripts/build-world-data.mjs for why this is not done on the phone. import type { DetailShapes } from '@/features/passport/data/world-map-detail-paths'; export const WORLD_FINE: DetailShapes = ${JSON.stringify(buildDetailShapes(WORLD_FEATURES.features, FINE_CLIP))}; `; } /** * Where the full-screen map has land, for the hand that moves it * (plate-land.ts): the cells of a half-degree grid the closest views' shapes * cover - the finer coasts where there are some, the world's own where not - * and the small countries' dots, drawn past the date line too, as runs of sea * and land a row. Given the shapes and the markers, so the build passes the * ones it has just made and tests/world-data-generated.test.mjs the ones the * app reads. */ export function generatedLandSource({ fine, countries, territories, markers }) { const paths = [...countries.map((shape) => fine.countries[shape.id] ?? shape.d), ...territories.map((shape) => fine.territories[shape.id] ?? shape.d)]; const land = markLand(rasterizeLand(paths), markers.map(([, x, y]) => ({ x, y }))); return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // Where the full-screen map has land: the cells of a half-degree grid // (plate-land.ts LAND_GRID) the drawn shapes and the small countries' dots // cover, as runs of sea and land a row, in base 36. Read when the full-screen // map opens, never with the passport's first screen. See plate-land.ts for // what the hand asks of it. export const WORLD_LAND_RUNS = ${JSON.stringify(encodeLand(land))}; `; } /** The countries too small to be seen, with their markers, found in the shapes just built. */ export function generatedMarkersSource() { return smallCountryMarkersSource(buildCountryShapes(WORLD_FEATURES.features), buildTerritoryShapes(WORLD_FEATURES.features)); } /** The world's territories: drawn, named in three languages, and flagged. */ export function generatedTerritoriesSource() { const shapes = buildTerritoryShapes(WORLD_FEATURES.features); const drawn = new Set(shapes.map((shape) => shape.id)); const info = Object.fromEntries( TERRITORIES.filter((territory) => drawn.has(territory.id)).map((territory) => [ territory.id, { ...(territory.isoCode ? { isoCode: territory.isoCode, flag: flagOfIsoCode(territory.isoCode) } : {}), name: TERRITORY_NAMES[territory.id], }, ]), ); return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // The world's territories - land that is not one of the 197 - as SVG path // data, with what each one is called in the three languages the app speaks. // See src/features/passport/data/territories.ts for what counts as one and // why, and scripts/build-world-data.mjs for why this is not done on the phone. import type { TerritoryShape } from '@/features/passport/data/world-map-paths'; export type TerritoryInfo = Readonly<{ /** Absent where a code's flag would be one side's in a dispute (Western Sahara): no code, no flag. */ isoCode?: string; flag?: string; name: Readonly<{ en: string; tr: string; es: string }>; }>; export const TERRITORY_SHAPES: readonly TerritoryShape[] = ${JSON.stringify(shapes)}; export const TERRITORY_INFO: Readonly> = ${JSON.stringify(info)}; `; } /** The world-countries records the app reads, cut to the fields it reads. */ export function liteCountries() { const wanted = new Set(COUNTRIES.map((country) => countryIsoCode(country)).filter(Boolean)); return worldCountries .filter((entry) => wanted.has(entry.cca2)) .map((entry) => ({ cca2: entry.cca2, cca3: entry.cca3, ccn3: entry.ccn3, name: { common: entry.name.common, official: entry.name.official }, translations: { tur: entry.translations?.tur ? { common: entry.translations.tur.common, official: entry.translations.tur.official } : undefined, spa: entry.translations?.spa ? { common: entry.translations.spa.common, official: entry.translations.spa.official } : undefined, }, capital: entry.capital, currencies: Object.fromEntries( Object.entries(entry.currencies ?? {}).map(([code, currency]) => [code, { name: currency.name, symbol: currency.symbol }]), ), // world-countries' languages, with the corrections in country-languages.mjs. languages: languagesFor(entry.cca2, entry.languages), subregion: entry.subregion, idd: { root: entry.idd?.root ?? '', suffixes: entry.idd?.suffixes ?? [] }, area: entry.area, landlocked: entry.landlocked, borders: entry.borders ?? [], latlng: entry.latlng, })); } export function generatedCountriesSource() { return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // The world-countries dataset (Mohammed Le Doze and contributors, ODbL 1.0), // cut to the Passport's countries and the fields the app reads. The package // itself is 565 KB of JSON, most of it translations the app never shows. export type WorldCountryLite = Readonly<{ cca2: string; cca3: string; ccn3: string; name: Readonly<{ common: string; official: string }>; translations: Readonly<{ tur?: Readonly<{ common: string; official: string }>; spa?: Readonly<{ common: string; official: string }> }>; capital: readonly string[]; currencies: Readonly>>; languages: Readonly>; subregion: string; idd: Readonly<{ root: string; suffixes: readonly string[] }>; area: number; landlocked: boolean; borders: readonly string[]; latlng: readonly [number, number]; }>; export const WORLD_COUNTRIES: readonly WorldCountryLite[] = ${JSON.stringify(liteCountries())}; `; } /** * What the passport's first screen reads of each record, keyed by its ISO * code: the names a reader sees (the state's own name in English, the common * and official names in Turkish and Spanish), the subregion its colour is * the colour of, its centre on the map, and its land borders, which the * "next country" card counts visited neighbours by. * * Five modules on that screen built a table from the records as they were * read - the names (country-names.ts), the colours (country-subregion.ts), * the map's centres (world-map-data.ts), search (country-search.ts) and the * neighbours (country-atlas.ts) - and so evaluated every capital, currency, * dialling code, area and language on launch, which only other screens * show. tests/world-data-generated.test.mjs holds these to the full records, * for every country and every language. */ export function slimCountries() { return Object.fromEntries( liteCountries().map((entry) => [ entry.cca2, { official: entry.name.official, tur: entry.translations.tur, spa: entry.translations.spa, subregion: entry.subregion, latlng: entry.latlng, cca3: entry.cca3, borders: entry.borders, }, ]), ); } export function generatedSlimCountriesSource() { return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // The world-countries dataset (Mohammed Le Doze and contributors, ODbL 1.0), // cut to what the passport's first screen reads of each country: its names, // its subregion, its centre and its neighbours. The whole record is // world-countries-lite.generated.ts; see slimCountries in // scripts/build-world-data.mjs for why this is apart. export type WorldCountrySlim = Readonly<{ /** The state's own name in English: "Islamic Republic of Afghanistan". */ official: string; tur?: Readonly<{ common: string; official: string }>; spa?: Readonly<{ common: string; official: string }>; subregion: string; latlng: readonly [number, number]; /** ISO 3166-1 alpha-3, the code the borders are listed by. */ cca3: string; borders: readonly string[]; }>; /** By ISO 3166-1 alpha-2 code, Kosovo's as XK. */ export const WORLD_COUNTRIES_SLIM: Readonly> = ${JSON.stringify(slimCountries())}; `; } /** * How far north and south each Passport country reaches, as the profile's * journey facts measure it: the mainland of the outlines the app decodes * (world-land.ts, from the same atlas), or the country's centre where the * atlas has no outline for it. * * The profile used to decode the 740 KB atlas for this while it drew - the * first time it opened in a session, in the middle of a frame - for two * numbers per country that change only when the atlas does. */ export function countryReach() { const land = buildWorldLand(worldAtlas).features; const centerByIsoCode = new Map(liteCountries().map((country) => [country.cca2, country.latlng])); return Object.fromEntries( COUNTRIES.flatMap((country) => { const geometries = land.filter((entry) => entry.properties.countryId === country.id).map((entry) => entry.geometry); const isoCode = countryIsoCode(country); const center = isoCode ? centerByIsoCode.get(isoCode) : undefined; const reach = mainlandReach(geometries) ?? (center ? { north: center[0], south: center[0] } : undefined); return reach ? [[country.id, reach]] : []; }), ); } export function generatedReachSource() { return `// GENERATED FILE - do not edit by hand. // Regenerate with: npm run world:build // // How far north and south each Passport country's mainland reaches, measured // from the world-atlas outlines at build time. See countryReach in // scripts/build-world-data.mjs. import type { Reach } from './journey-facts'; export const COUNTRY_REACH: Readonly> = ${JSON.stringify(countryReach())}; `; } if (process.argv[1] === fileURLToPath(import.meta.url)) { const shapes = generatedShapesSource(); const countries = generatedCountriesSource(); const territories = generatedTerritoriesSource(); writeFileSync(out('world-map-shapes.generated.ts'), shapes); writeFileSync(out('world-countries-lite.generated.ts'), countries); writeFileSync(out('world-countries-slim.generated.ts'), generatedSlimCountriesSource()); writeFileSync(out('world-map-territories.generated.ts'), territories); writeFileSync(out('world-map-markers.generated.ts'), generatedMarkersSource()); writeFileSync(out('country-reach.generated.ts'), generatedReachSource()); const fine = generatedFineSource(); writeFileSync(out('world-map-fine.generated.ts'), fine); console.log(`fine coasts ${(fine.length / 1024).toFixed(0)} KB`); const land = generatedLandSource({ fine: buildDetailShapes(WORLD_FEATURES.features, FINE_CLIP), countries: buildCountryShapes(WORLD_FEATURES.features), territories: buildTerritoryShapes(WORLD_FEATURES.features), markers: deriveSmallCountryMarkers(buildCountryShapes(WORLD_FEATURES.features), buildTerritoryShapes(WORLD_FEATURES.features)), }); writeFileSync(out('world-land-mask.generated.ts'), land); console.log(`land ${(land.length / 1024).toFixed(0)} KB`); console.log(`shapes ${(shapes.length / 1024).toFixed(0)} KB, countries ${(countries.length / 1024).toFixed(0)} KB (${liteCountries().length} records), territories ${(territories.length / 1024).toFixed(0)} KB`); }