/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * Geographic helpers: bbox math, coordinate validation, GeoJSON building. */ import type { BBox, MapProperty } from "../types/index.js"; /** Quebec-ish sanity envelope used to reject obviously bad coordinates. */ const LAT_MIN = -90; const LAT_MAX = 90; const LNG_MIN = -180; const LNG_MAX = 180; /** True when a lat/lng pair is a plausible, finite coordinate. */ export function isValidCoordinate(lat: unknown, lng: unknown): boolean { return ( typeof lat === "number" && typeof lng === "number" && Number.isFinite(lat) && Number.isFinite(lng) && lat >= LAT_MIN && lat <= LAT_MAX && lng >= LNG_MIN && lng <= LNG_MAX && // (0, 0) is the classic failed-geocode sentinel — never a Quebec property. !(lat === 0 && lng === 0) ); } /** Round a bbox for stable cache keys and short URLs. */ export function roundBBox(bbox: BBox, decimals = 5): BBox { const f = 10 ** decimals; const r = (v: number) => Math.round(v * f) / f; return { west: r(bbox.west), south: r(bbox.south), east: r(bbox.east), north: r(bbox.north) }; } /** Serialize as the canonical "west,south,east,north" API parameter. */ export function bboxToString(bbox: BBox, decimals = 5): string { const r = roundBBox(bbox, decimals); return `${r.west},${r.south},${r.east},${r.north}`; } /** Parse "west,south,east,north"; returns null when malformed. */ export function parseBBox(text: string): BBox | null { const parts = text.split(",").map(Number); if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p))) return null; const [west, south, east, north] = parts as [number, number, number, number]; if (south > north || west > east) return null; if (!isValidCoordinate(south, west) || !isValidCoordinate(north, east)) return null; return { west, south, east, north }; } /** Expand a bbox by a ratio (0.2 → 20 % margin) to prefetch around the viewport. */ export function expandBBox(bbox: BBox, ratio: number): BBox { const dLng = (bbox.east - bbox.west) * ratio; const dLat = (bbox.north - bbox.south) * ratio; return { west: Math.max(LNG_MIN, bbox.west - dLng), south: Math.max(LAT_MIN, bbox.south - dLat), east: Math.min(LNG_MAX, bbox.east + dLng), north: Math.min(LAT_MAX, bbox.north + dLat), }; } /** True when `inner` is fully contained in `outer`. */ export function bboxContains(outer: BBox, inner: BBox): boolean { return ( inner.west >= outer.west && inner.east <= outer.east && inner.south >= outer.south && inner.north <= outer.north ); } /** True when a point falls inside a bbox. */ export function bboxContainsPoint(bbox: BBox, lat: number, lng: number): boolean { return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east; } /** Emprise englobant un ensemble de propriétés (fitBounds sur les * résultats) ; null si aucune coordonnée valide. Un point unique donne * une emprise dégénérée — prévoir un maxZoom au fitBounds. */ export function bboxOfProperties(properties: MapProperty[]): BBox | null { let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity; let n = 0; for (const p of properties) { if (!isValidCoordinate(p.latitude, p.longitude)) continue; n++; if (p.longitude < west) west = p.longitude; if (p.longitude > east) east = p.longitude; if (p.latitude < south) south = p.latitude; if (p.latitude > north) north = p.latitude; } return n === 0 ? null : { west, south, east, north }; } /** Test point-dans-polygone (ray casting) — anneau [lng, lat][]. */ export function pointInPolygon( lng: number, lat: number, ring: [number, number][], ): boolean { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i] as [number, number]; const [xj, yj] = ring[j] as [number, number]; if (yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; } /** Great-circle distance in metres (haversine). */ export function haversineMeters( lat1: number, lng1: number, lat2: number, lng2: number, ): number { const R = 6_371_000; const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } /** Build the GeoJSON FeatureCollection fed to the MapLibre property source. */ export function propertiesToGeoJSON( properties: MapProperty[], ): GeoJSON.FeatureCollection { const features: GeoJSON.Feature[] = []; for (const p of properties) { if (!isValidCoordinate(p.latitude, p.longitude)) continue; features.push({ type: "Feature", id: hashId(p.id), geometry: { type: "Point", coordinates: [p.longitude, p.latitude] }, properties: { id: p.id, kind: p.kind, listingType: p.listingType ?? null, // A single numeric "labelValue" drives labels & cluster medians: // asking price for listings, estimated value for valuations. labelValue: p.kind === "valuation" ? p.estimatedValue ?? null : p.price ?? null, price: p.price ?? null, estimatedValue: p.estimatedValue ?? null, propertyType: p.propertyType ?? null, bedrooms: p.bedrooms ?? null, priceChange: p.priceChange ?? null, highlight: p.highlight === true ? 1 : 0, }, }); } return { type: "FeatureCollection", features }; } /** * MapLibre feature-state requires numeric/string feature ids; app ids are * strings, so we derive a stable 32-bit hash. Collisions are astronomically * unlikely within one viewport and only affect hover styling. */ export function hashId(id: string): number { let h = 2166136261; for (let i = 0; i < id.length; i++) { h ^= id.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }