SPB Git forge

spb/ka-maps

Public
6commits 1branches 0releases
448.0 KBsize
maindefault branch
29 days agolast push
TypeScript 87.7% CSS 12.3%
6.0 KB · 177 lines typescript
Raw Blame History
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * Geographic helpers: bbox math, coordinate validation, GeoJSON building.7 */89import type { BBox, MapProperty } from "../types/index.js";1011/** Quebec-ish sanity envelope used to reject obviously bad coordinates. */12const LAT_MIN = -90;13const LAT_MAX = 90;14const LNG_MIN = -180;15const LNG_MAX = 180;1617/** True when a lat/lng pair is a plausible, finite coordinate. */18export function isValidCoordinate(lat: unknown, lng: unknown): boolean {19  return (20    typeof lat === "number" &&21    typeof lng === "number" &&22    Number.isFinite(lat) &&23    Number.isFinite(lng) &&24    lat >= LAT_MIN &&25    lat <= LAT_MAX &&26    lng >= LNG_MIN &&27    lng <= LNG_MAX &&28    // (0, 0) is the classic failed-geocode sentinel — never a Quebec property.29    !(lat === 0 && lng === 0)30  );31}3233/** Round a bbox for stable cache keys and short URLs. */34export function roundBBox(bbox: BBox, decimals = 5): BBox {35  const f = 10 ** decimals;36  const r = (v: number) => Math.round(v * f) / f;37  return { west: r(bbox.west), south: r(bbox.south), east: r(bbox.east), north: r(bbox.north) };38}3940/** Serialize as the canonical "west,south,east,north" API parameter. */41export function bboxToString(bbox: BBox, decimals = 5): string {42  const r = roundBBox(bbox, decimals);43  return `${r.west},${r.south},${r.east},${r.north}`;44}4546/** Parse "west,south,east,north"; returns null when malformed. */47export function parseBBox(text: string): BBox | null {48  const parts = text.split(",").map(Number);49  if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p))) return null;50  const [west, south, east, north] = parts as [number, number, number, number];51  if (south > north || west > east) return null;52  if (!isValidCoordinate(south, west) || !isValidCoordinate(north, east)) return null;53  return { west, south, east, north };54}5556/** Expand a bbox by a ratio (0.2 → 20 % margin) to prefetch around the viewport. */57export function expandBBox(bbox: BBox, ratio: number): BBox {58  const dLng = (bbox.east - bbox.west) * ratio;59  const dLat = (bbox.north - bbox.south) * ratio;60  return {61    west: Math.max(LNG_MIN, bbox.west - dLng),62    south: Math.max(LAT_MIN, bbox.south - dLat),63    east: Math.min(LNG_MAX, bbox.east + dLng),64    north: Math.min(LAT_MAX, bbox.north + dLat),65  };66}6768/** True when `inner` is fully contained in `outer`. */69export function bboxContains(outer: BBox, inner: BBox): boolean {70  return (71    inner.west >= outer.west &&72    inner.east <= outer.east &&73    inner.south >= outer.south &&74    inner.north <= outer.north75  );76}7778/** True when a point falls inside a bbox. */79export function bboxContainsPoint(bbox: BBox, lat: number, lng: number): boolean {80  return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east;81}8283/** Emprise englobant un ensemble de propriétés (fitBounds sur les84 *  résultats) ; null si aucune coordonnée valide. Un point unique donne85 *  une emprise dégénérée — prévoir un maxZoom au fitBounds. */86export function bboxOfProperties(properties: MapProperty[]): BBox | null {87  let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity;88  let n = 0;89  for (const p of properties) {90    if (!isValidCoordinate(p.latitude, p.longitude)) continue;91    n++;92    if (p.longitude < west) west = p.longitude;93    if (p.longitude > east) east = p.longitude;94    if (p.latitude < south) south = p.latitude;95    if (p.latitude > north) north = p.latitude;96  }97  return n === 0 ? null : { west, south, east, north };98}99100/** Test point-dans-polygone (ray casting) — anneau [lng, lat][]. */101export function pointInPolygon(102  lng: number,103  lat: number,104  ring: [number, number][],105): boolean {106  let inside = false;107  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {108    const [xi, yi] = ring[i] as [number, number];109    const [xj, yj] = ring[j] as [number, number];110    if (yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {111      inside = !inside;112    }113  }114  return inside;115}116117/** Great-circle distance in metres (haversine). */118export function haversineMeters(119  lat1: number,120  lng1: number,121  lat2: number,122  lng2: number,123): number {124  const R = 6_371_000;125  const toRad = (d: number) => (d * Math.PI) / 180;126  const dLat = toRad(lat2 - lat1);127  const dLng = toRad(lng2 - lng1);128  const a =129    Math.sin(dLat / 2) ** 2 +130    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;131  return 2 * R * Math.asin(Math.sqrt(a));132}133134/** Build the GeoJSON FeatureCollection fed to the MapLibre property source. */135export function propertiesToGeoJSON(136  properties: MapProperty[],137): GeoJSON.FeatureCollection<GeoJSON.Point> {138  const features: GeoJSON.Feature<GeoJSON.Point>[] = [];139  for (const p of properties) {140    if (!isValidCoordinate(p.latitude, p.longitude)) continue;141    features.push({142      type: "Feature",143      id: hashId(p.id),144      geometry: { type: "Point", coordinates: [p.longitude, p.latitude] },145      properties: {146        id: p.id,147        kind: p.kind,148        listingType: p.listingType ?? null,149        // A single numeric "labelValue" drives labels & cluster medians:150        // asking price for listings, estimated value for valuations.151        labelValue: p.kind === "valuation" ? p.estimatedValue ?? null : p.price ?? null,152        price: p.price ?? null,153        estimatedValue: p.estimatedValue ?? null,154        propertyType: p.propertyType ?? null,155        bedrooms: p.bedrooms ?? null,156        priceChange: p.priceChange ?? null,157        highlight: p.highlight === true ? 1 : 0,158      },159    });160  }161  return { type: "FeatureCollection", features };162}163164/**165 * MapLibre feature-state requires numeric/string feature ids; app ids are166 * strings, so we derive a stable 32-bit hash. Collisions are astronomically167 * unlikely within one viewport and only affect hover styling.168 */169export function hashId(id: string): number {170  let h = 2166136261;171  for (let i = 0; i < id.length; i++) {172    h ^= id.charCodeAt(i);173    h = Math.imul(h, 16777619);174  }175  return h >>> 0;176}177