Add dictionary lookup helpers and findCityByCoord

This commit is contained in:
2026-04-17 03:14:28 +03:00
parent 64dd5421e2
commit e575c1baa1
2 changed files with 142 additions and 0 deletions
+66
View File
@@ -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();
});
});
+76
View File
@@ -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;
}