228 lines
6.4 KiB
JavaScript
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));
|
|
}
|