// countries.js - country data and helpers /** * @typedef {{ name: string, lon: number, lat: number }} CitySource * @typedef {{ name: string, x: number, y: number }} City * @typedef {{ minLon: number, maxLon: number, minLat: number, maxLat: number }} Bounds * @typedef {{ minX: number, maxX: number, minY: number, maxY: number }} ProjectedBounds * @typedef {{ padding: number, scale: number, xOffset: number, yOffset: number }} Projection * @typedef {{ type: string, geoBounds?: Bounds, projectedBounds?: ProjectedBounds, bounds?: Bounds, projection?: Projection, rings: { x: number, y: number }[][] }} CountryOutline * @typedef {{ name: string, hint: string, file: string, cities: CitySource[] }} CountrySource * @typedef {{ name: string, hint: string, cities: City[], outline: CountryOutline }} Country */ const MAX_MERCATOR_LAT = 85.05112878; /** @type {Country[]} */ let data = []; /** * Load country data and outlines into memory. Safe to call multiple times. * @returns {Promise} */ export async function loadCountries() { if (data.length) return data; const countries = await loadCountrySources(); data = await Promise.all( countries.map(async (country) => { const response = await fetch( new URL(`../data/outlines/${country.file}`, import.meta.url), ); if (!response.ok) { throw new Error(`Could not load outline for ${country.name}.`); } const outlineData = await response.json(); const outline = outlineData.outline; return { name: country.name, hint: country.hint, cities: projectCities(country.cities, outline), outline, }; }), ); return data; } /** * Load and validate country metadata from managed JSON. * @returns {Promise} */ async function loadCountrySources() { const response = await fetch(new URL("../data/countries.json", import.meta.url)); if (!response.ok) { throw new Error("Could not load country metadata."); } const countries = await response.json(); if (!Array.isArray(countries)) { throw new Error("Country metadata must be an array."); } return countries.map(validateCountrySource); } /** * @param {unknown} value * @param {number} index * @returns {CountrySource} */ function validateCountrySource(value, index) { if (!value || typeof value !== "object") { throw new Error(`Country metadata entry ${index + 1} must be an object.`); } const country = /** @type {Record} */ (value); if (typeof country.name !== "string" || country.name.trim() === "") { throw new Error(`Country metadata entry ${index + 1} is missing a name.`); } if (typeof country.file !== "string" || country.file.trim() === "") { throw new Error(`Country metadata entry ${country.name} is missing a file.`); } if (!Array.isArray(country.cities)) { throw new Error(`Country metadata entry ${country.name} is missing cities.`); } return { name: country.name, file: country.file, hint: typeof country.hint === "string" ? country.hint : "", cities: country.cities.map((city, cityIndex) => validateCitySource(city, country.name, cityIndex), ), }; } /** * @param {unknown} value * @param {string} countryName * @param {number} index * @returns {CitySource} */ function validateCitySource(value, countryName, index) { if (!value || typeof value !== "object") { throw new Error(`City ${index + 1} for ${countryName} must be an object.`); } const city = /** @type {Record} */ (value); if (typeof city.name !== "string" || city.name.trim() === "") { throw new Error(`City ${index + 1} for ${countryName} is missing a name.`); } if (typeof city.lon !== "number" || !Number.isFinite(city.lon)) { throw new Error(`${city.name} for ${countryName} is missing a numeric lon.`); } if (typeof city.lat !== "number" || !Number.isFinite(city.lat)) { throw new Error(`${city.name} for ${countryName} is missing a numeric lat.`); } return { name: city.name, lon: city.lon, lat: city.lat, }; } /** * Return a random subset of countries. * @param {number} count * @returns {Country[]} */ export function getRandomCountries(count = 3) { return [...data].sort(() => Math.random() - 0.5).slice(0, count); } /** * Get city list for a specific country by name. * @param {string} countryName * @returns {City[]} */ export function getCities(countryName) { const country = data.find((c) => c.name === countryName); return country ? country.cities : []; } /** * @param {CitySource[]} cities * @param {CountryOutline} outline * @returns {City[]} */ function projectCities(cities, outline) { const { bounds, geoBounds, projectedBounds, projection } = outline; if (projectedBounds && projection) { return cities.map((city) => { const projected = mercatorProject(city.lon, city.lat); return { name: city.name, x: clampPercent(projectProjectedX(projected.x, projectedBounds, projection)), y: clampPercent(projectProjectedY(projected.y, projectedBounds, projection)), }; }); } if (!bounds && !geoBounds) return []; const fallbackBounds = bounds || geoBounds; if (!fallbackBounds) return []; const lonSpan = fallbackBounds.maxLon - fallbackBounds.minLon; const latSpan = fallbackBounds.maxLat - fallbackBounds.minLat; return cities.map((city) => ({ name: city.name, x: clampPercent( lonSpan === 0 ? 50 : ((city.lon - fallbackBounds.minLon) / lonSpan) * 100, ), y: clampPercent( latSpan === 0 ? 50 : (1 - (city.lat - fallbackBounds.minLat) / latSpan) * 100, ), })); } /** * @param {number} lon * @param {number} lat * @returns {{ x: number, y: number }} */ function mercatorProject(lon, lat) { const clampedLat = Math.max( -MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat), ); const lonRad = (lon * Math.PI) / 180; const latRad = (clampedLat * Math.PI) / 180; return { x: lonRad, y: Math.log(Math.tan(Math.PI / 4 + latRad / 2)), }; } /** * @param {number} x * @param {ProjectedBounds} bounds * @param {Projection} projection */ function projectProjectedX(x, bounds, projection) { return projection.scale === 0 ? 50 : projection.xOffset + (x - bounds.minX) * projection.scale; } /** * @param {number} y * @param {ProjectedBounds} bounds * @param {Projection} projection */ function projectProjectedY(y, bounds, projection) { return projection.scale === 0 ? 50 : projection.yOffset + (bounds.maxY - y) * projection.scale; } /** @param {number} value */ function clampPercent(value) { return Math.max(0, Math.min(100, Math.round(value * 100) / 100)); }