diff --git a/src/shared/dictionaries/transform.test.ts b/src/shared/dictionaries/transform.test.ts index 2f746493..fca02410 100644 --- a/src/shared/dictionaries/transform.test.ts +++ b/src/shared/dictionaries/transform.test.ts @@ -211,3 +211,69 @@ describe("transformDictionaries — regions", () => { expect(d.regions.map((r) => r.id)).toEqual([500374, 1, 2]); }); }); + +import { + getCityByCode, + getAirportByCode, + getCityCodeByAirportCode, + getCityOrAirport, + findCityByCoord, +} from "./transform.js"; + +describe("helpers", () => { + const baseline = transformDictionaries( + { + regions: [], + countries: [], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 59, lon: 30 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 59, lon: 30 } }, + ], + }, + "ru", + ); + + it("getCityByCode is case-insensitive", () => { + expect(getCityByCode(baseline, "mow")?.code).toBe("MOW"); + expect(getCityByCode(baseline, "MOW")?.code).toBe("MOW"); + }); + + it("getAirportByCode is case-insensitive", () => { + expect(getAirportByCode(baseline, "svo")?.code).toBe("SVO"); + }); + + it("getCityCodeByAirportCode maps airport → city", () => { + expect(getCityCodeByAirportCode(baseline, "SVO")).toBe("MOW"); + expect(getCityCodeByAirportCode(baseline, "NOPE")).toBeUndefined(); + }); + + it("getCityOrAirport prefers city on collision", () => { + const hit = getCityOrAirport(baseline, "LED"); + expect(hit && "country_code" in hit).toBe(true); + }); + + it("getCityOrAirport falls back to airport when code is airport-only", () => { + const hit = getCityOrAirport(baseline, "SVO"); + expect(hit && "city_code" in hit).toBe(true); + }); + + it("findCityByCoord returns the nearest city by haversine distance", () => { + const near = findCityByCoord(baseline, 55.1, 37.1); + expect(near?.code).toBe("MOW"); + + const far = findCityByCoord(baseline, 59.5, 30.5); + expect(far?.code).toBe("LED"); + }); + + it("findCityByCoord returns undefined when no cities exist", () => { + const empty = transformDictionaries( + { regions: [], countries: [], cities: [], airports: [] }, + "ru", + ); + expect(findCityByCoord(empty, 0, 0)).toBeUndefined(); + }); +}); diff --git a/src/shared/dictionaries/transform.ts b/src/shared/dictionaries/transform.ts index a3aef17b..c780cf02 100644 --- a/src/shared/dictionaries/transform.ts +++ b/src/shared/dictionaries/transform.ts @@ -134,3 +134,79 @@ function buildRegions( countries: countries.filter((c) => c.world_region_id === r.world_region_id), })); } + +// --------------------------------------------------------------------------- +// Helpers (consumer-facing; pure, take dictionaries as argument) +// --------------------------------------------------------------------------- + +export function getCityByCode( + d: IDictionaries, + code: string, +): ICity | undefined { + return d.cityByCode.get(code.toUpperCase()); +} + +export function getAirportByCode( + d: IDictionaries, + code: string, +): IAirport | undefined { + return d.airportByCode.get(code.toUpperCase()); +} + +export function getCityCodeByAirportCode( + d: IDictionaries, + airportCode: string, +): string | undefined { + return d.airportByCode.get(airportCode.toUpperCase())?.city_code; +} + +export function getCityOrAirport( + d: IDictionaries, + code: string, +): ICity | IAirport | undefined { + return getCityByCode(d, code) ?? getAirportByCode(d, code); +} + +/** + * Returns the city whose location is the nearest to the given lat/lon + * by great-circle (haversine) distance. Returns undefined when the + * dictionaries have no cities. + */ +export function findCityByCoord( + d: IDictionaries, + lat: number, + lon: number, +): ICity | undefined { + if (d.cities.length === 0) return undefined; + + let best: ICity | undefined; + let bestDist = Infinity; + for (const city of d.cities) { + const dist = haversineKm(lat, lon, city.location.lat, city.location.lon); + if (dist < bestDist) { + bestDist = dist; + best = city; + } + } + return best; +} + +function haversineKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number { + const R = 6371; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.sin(dLon / 2) ** 2 * Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function toRad(deg: number): number { + return (deg * Math.PI) / 180; +}