TypeScript 87.7%
CSS 12.3%
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