228 lines
6.4 KiB
JavaScript

// 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<Country[]>}
*/
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<CountrySource[]>}
*/
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<string, unknown>} */ (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<string, unknown>} */ (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));
}