Add dictionary lookup helpers and findCityByCoord
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user