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%
3.4 KB · 98 lines typescript
Raw Blame History
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