TypeScript 87.7%
CSS 12.3%
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Groupe Ka / Ka Maps5 *6 * Ka Lens — statistiques d'une sélection géographique. Calcule sur les7 * MapProperty réellement chargées (viewport ou polygone) ; seules les8 * métriques dont les données existent sont renseignées — jamais de9 * valeurs inventées. Les agrégats temporels (7 j/30 j/1 an) relèvent des10 * API d'agrégation des apps (GeographicMarketSummary) quand elles11 * existeront.12 */1314import type { BBox, KaLensStats, MapProperty } from "../types/index.js";15import { bboxContainsPoint } from "./geo.js";16import { median } from "./format.js";1718/** Statistiques Ka Lens sur un ensemble de propriétés. */19export function computeLensStats(properties: MapProperty[]): KaLensStats {20 const count = properties.length;21 const prices = properties22 .map((p) => p.price)23 .filter((v): v is number => Number.isFinite(v as number));24 const estimates = properties25 .map((p) => p.estimatedValue)26 .filter((v): v is number => Number.isFinite(v as number));27 const dom = properties28 .map((p) => p.daysOnMarket)29 .filter((v): v is number => Number.isFinite(v as number));30 const cuts = properties.filter(31 (p) => typeof p.priceChange === "number" && p.priceChange < 0,32 ).length;33 const stale = properties.filter(34 (p) => typeof p.daysOnMarket === "number" && p.daysOnMarket > 90,35 ).length;3637 const typeCounts = new Map<string, number>();38 for (const p of properties) {39 if (!p.propertyType) continue;40 typeCounts.set(p.propertyType, (typeCounts.get(p.propertyType) ?? 0) + 1);41 }42 const typed = [...typeCounts.values()].reduce((a, b) => a + b, 0);4344 const stats: KaLensStats = { count };45 const medPrice = median(prices);46 if (medPrice !== undefined) stats.medianPrice = medPrice;47 const medEst = median(estimates);48 if (medEst !== undefined) stats.medianEstimatedValue = medEst;49 const medDom = median(dom);50 if (medDom !== undefined) stats.medianDaysOnMarket = medDom;51 if (dom.length > 0) stats.stale90dShare = stale / dom.length;52 if (properties.some((p) => typeof p.priceChange === "number")) {53 stats.priceCutShare = count > 0 ? cuts / count : 0;54 }55 if (typed > 0) {56 stats.typeMix = Object.fromEntries(57 [...typeCounts.entries()]58 .sort((a, b) => b[1] - a[1])59 .map(([k, v]) => [k, v / typed]),60 );61 }62 return stats;63}6465/** Sous-ensemble des propriétés dans un rectangle (sélection visible). */66export function propertiesInBBox(67 properties: MapProperty[],68 bbox: BBox,69): MapProperty[] {70 return properties.filter((p) =>71 bboxContainsPoint(bbox, p.latitude, p.longitude),72 );73}7475/** Sous-ensemble dans un polygone GeoJSON (Ka Lens dessiné) — ray casting. */76export function propertiesInPolygon(77 properties: MapProperty[],78 polygon: GeoJSON.Polygon,79): MapProperty[] {80 const ring = polygon.coordinates[0];81 if (!ring || ring.length < 4) return [];82 return properties.filter((p) => pointInRing(ring, p.longitude, p.latitude));83}8485function pointInRing(ring: GeoJSON.Position[], x: number, y: number): boolean {86 let inside = false;87 for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {88 const xi = ring[i]![0]!;89 const yi = ring[i]![1]!;90 const xj = ring[j]![0]!;91 const yj = ring[j]![1]!;92 if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {93 inside = !inside;94 }95 }96 return inside;97}98