← back to Homesonspec
packages/shared/src/geo.ts
109 lines
/**
* All geo math lives here. v1 uses plain lat/lon Decimal columns + btree
* indexes; a later PostGIS adoption swaps this module + one migration
* without touching callers.
*/
export interface BBox {
minLat: number;
maxLat: number;
minLon: number;
maxLon: number;
}
const MILES_PER_DEGREE_LAT = 69.0;
/** Bounding box around a center point. Good enough for search-radius UX. */
export function bboxAround(lat: number, lon: number, radiusMiles: number): BBox {
const dLat = radiusMiles / MILES_PER_DEGREE_LAT;
const dLon = radiusMiles / (MILES_PER_DEGREE_LAT * Math.cos((lat * Math.PI) / 180));
return {
minLat: lat - dLat,
maxLat: lat + dLat,
minLon: lon - dLon,
maxLon: lon + dLon,
};
}
/** Great-circle distance in miles. */
export function haversineMiles(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 3958.8;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
/**
* Per-state bounding boxes (contiguous-US states we validate against).
* Used by the geo.latlon-in-state rule. Coarse boxes are fine — the rule
* catches gross errors (wrong hemisphere, swapped lat/lon), not surveying.
*/
export const STATE_BBOXES: Record<string, BBox> = {
TX: { minLat: 25.8, maxLat: 36.5, minLon: -106.7, maxLon: -93.5 },
AZ: { minLat: 31.3, maxLat: 37.0, minLon: -114.9, maxLon: -109.0 },
NC: { minLat: 33.8, maxLat: 36.6, minLon: -84.4, maxLon: -75.4 },
FL: { minLat: 24.4, maxLat: 31.0, minLon: -87.7, maxLon: -80.0 },
CA: { minLat: 32.5, maxLat: 42.0, minLon: -124.5, maxLon: -114.1 },
CO: { minLat: 36.9, maxLat: 41.0, minLon: -109.1, maxLon: -102.0 },
GA: { minLat: 30.3, maxLat: 35.0, minLon: -85.7, maxLon: -80.8 },
TN: { minLat: 34.9, maxLat: 36.7, minLon: -90.4, maxLon: -81.6 },
SC: { minLat: 32.0, maxLat: 35.3, minLon: -83.4, maxLon: -78.5 },
NV: { minLat: 35.0, maxLat: 42.0, minLon: -120.0, maxLon: -114.0 },
};
export function latLonInState(lat: number, lon: number, state: string): boolean | null {
const box = STATE_BBOXES[state.toUpperCase()];
if (!box) return null; // unknown state → rule reports "cannot verify", not failure
return lat >= box.minLat && lat <= box.maxLat && lon >= box.minLon && lon <= box.maxLon;
}
/**
* ZIP3 prefix → state. Covers the prefixes used by our validated states.
* The geo.state-zip-agreement rule returns null (cannot verify) for
* prefixes not in this table rather than guessing.
*/
export const ZIP3_TO_STATE: Record<string, string> = {
// Texas (Austin metro + neighbors)
"733": "TX", "786": "TX", "787": "TX", "789": "TX", "750": "TX", "751": "TX",
"752": "TX", "760": "TX", "770": "TX", "773": "TX", "775": "TX", "782": "TX",
// Arizona (Phoenix metro)
"850": "AZ", "851": "AZ", "852": "AZ", "853": "AZ", "857": "AZ", "859": "AZ", "863": "AZ",
// North Carolina (Raleigh metro + neighbors)
"275": "NC", "276": "NC", "277": "NC", "278": "NC", "270": "NC", "271": "NC",
"272": "NC", "273": "NC", "274": "NC", "280": "NC", "281": "NC", "282": "NC",
};
export function stateForZip(zip: string): string | null {
const prefix = zip.slice(0, 3);
return ZIP3_TO_STATE[prefix] ?? null;
}
const STATE_NAME_TO_CODE: Record<string, string> = {
alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA",
colorado: "CO", connecticut: "CT", delaware: "DE", florida: "FL", georgia: "GA",
hawaii: "HI", idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA",
kansas: "KS", kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD",
massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS", missouri: "MO",
montana: "MT", nebraska: "NE", nevada: "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", ohio: "OH",
oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", tennessee: "TN", texas: "TX", utah: "UT", vermont: "VT",
virginia: "VA", washington: "WA", "west virginia": "WV", wisconsin: "WI", wyoming: "WY",
"district of columbia": "DC",
};
/** "New Jersey" | "new-jersey" | "NJ" → "NJ". Null when unrecognized — never guessed. */
export function normalizeStateCode(raw: string | null | undefined): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (/^[A-Za-z]{2}$/.test(trimmed)) {
const upper = trimmed.toUpperCase();
return Object.values(STATE_NAME_TO_CODE).includes(upper) ? upper : null;
}
return STATE_NAME_TO_CODE[trimmed.toLowerCase().replace(/-/g, " ")] ?? null;
}