/** * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Project: Groupe Ka / Ka Maps * * Ka Lens — statistiques d'une sélection géographique. Calcule sur les * MapProperty réellement chargées (viewport ou polygone) ; seules les * métriques dont les données existent sont renseignées — jamais de * valeurs inventées. Les agrégats temporels (7 j/30 j/1 an) relèvent des * API d'agrégation des apps (GeographicMarketSummary) quand elles * existeront. */ import type { BBox, KaLensStats, MapProperty } from "../types/index.js"; import { bboxContainsPoint } from "./geo.js"; import { median } from "./format.js"; /** Statistiques Ka Lens sur un ensemble de propriétés. */ export function computeLensStats(properties: MapProperty[]): KaLensStats { const count = properties.length; const prices = properties .map((p) => p.price) .filter((v): v is number => Number.isFinite(v as number)); const estimates = properties .map((p) => p.estimatedValue) .filter((v): v is number => Number.isFinite(v as number)); const dom = properties .map((p) => p.daysOnMarket) .filter((v): v is number => Number.isFinite(v as number)); const cuts = properties.filter( (p) => typeof p.priceChange === "number" && p.priceChange < 0, ).length; const stale = properties.filter( (p) => typeof p.daysOnMarket === "number" && p.daysOnMarket > 90, ).length; const typeCounts = new Map(); for (const p of properties) { if (!p.propertyType) continue; typeCounts.set(p.propertyType, (typeCounts.get(p.propertyType) ?? 0) + 1); } const typed = [...typeCounts.values()].reduce((a, b) => a + b, 0); const stats: KaLensStats = { count }; const medPrice = median(prices); if (medPrice !== undefined) stats.medianPrice = medPrice; const medEst = median(estimates); if (medEst !== undefined) stats.medianEstimatedValue = medEst; const medDom = median(dom); if (medDom !== undefined) stats.medianDaysOnMarket = medDom; if (dom.length > 0) stats.stale90dShare = stale / dom.length; if (properties.some((p) => typeof p.priceChange === "number")) { stats.priceCutShare = count > 0 ? cuts / count : 0; } if (typed > 0) { stats.typeMix = Object.fromEntries( [...typeCounts.entries()] .sort((a, b) => b[1] - a[1]) .map(([k, v]) => [k, v / typed]), ); } return stats; } /** Sous-ensemble des propriétés dans un rectangle (sélection visible). */ export function propertiesInBBox( properties: MapProperty[], bbox: BBox, ): MapProperty[] { return properties.filter((p) => bboxContainsPoint(bbox, p.latitude, p.longitude), ); } /** Sous-ensemble dans un polygone GeoJSON (Ka Lens dessiné) — ray casting. */ export function propertiesInPolygon( properties: MapProperty[], polygon: GeoJSON.Polygon, ): MapProperty[] { const ring = polygon.coordinates[0]; if (!ring || ring.length < 4) return []; return properties.filter((p) => pointInRing(ring, p.longitude, p.latitude)); } function pointInRing(ring: GeoJSON.Position[], x: number, y: number): boolean { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const xi = ring[i]![0]!; const yi = ring[i]![1]!; const xj = ring[j]![0]!; const yj = ring[j]![1]!; if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; }