← 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;
}