SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.5 KB · 125 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/InteractiveMap.tsx : grande carte 3D de la fiche (Ka Maps / Mapbox) —5//   immeuble surligné en orange, lignes de métro, et FILTRES de lieux6//   (Transport · Épiceries · Pharmacies · Commerces · Écoles · Parcs · Essence)7//   dessinés comme couche GeoJSON (cercle coloré + étiquette). Les lieux8//   viennent des données déjà chargées (commerces/transit Mapbox-OSM, POI OSM9//   avec coordonnées, stations gazquebec) ; un filtre sans coordonnée connue10//   est désactivé (jamais de point inventé). La caméra recule pour englober11//   les lieux affichés puis revient sur l'immeuble quand aucun filtre n'est actif.12// -----------------------------------------------------------------------------13import { lazy, Suspense, useEffect, useMemo, useState } from "react";14import type { CommercesNearby, GazNearby, Listing, Poi } from "../api";15import { fmtDist } from "../api";16import { IcoMapPin } from "../components/Icons";17import { SectionCard, Skeleton } from "./ui";1819const MapInner = lazy(() => import("./MapInner"));2021export interface Lieu { id: string; cat: string; name: string; sub?: string; lat: number; lng: number; dist_m: number; }22export interface Categorie { key: string; label: string; color: string; }2324export const CATEGORIES: Categorie[] = [25  { key: "transport", label: "Transport", color: "#0083c9" },26  { key: "epicerie", label: "Épiceries", color: "#1e7b4a" },27  { key: "pharmacie", label: "Pharmacies", color: "#c2185b" },28  { key: "commerce", label: "Commerces", color: "#5c4bb5" },29  { key: "ecole", label: "Écoles", color: "#b8770b" },30  { key: "parc", label: "Parcs", color: "#3d8f3d" },31  { key: "essence", label: "Essence", color: "#4e5357" },32];3334const COMMERCE_CAT: Record<string, string> = {35  metro_station: "transport", rem_station: "transport", arret_bus: "transport", gare_train: "transport",36  costco: "epicerie", walmart: "epicerie", metro: "epicerie", iga: "epicerie", maxi: "epicerie",37  superc: "epicerie", provigo: "epicerie", pharmaprix: "pharmacie", jeancoutu: "pharmacie",38};39const POI_CAT: Record<string, string> = {40  epicerie: "epicerie", depanneur: "commerce", pharmacie: "pharmacie", ecole: "ecole", garderie: "ecole",41  bibliotheque: "ecole", parc: "parc", bus: "transport", metro: "transport", gym: "commerce", cafe: "commerce",42  clinique: "commerce", hopital: "commerce",43};4445/** Fusionne toutes les sources de lieux géolocalisés (dédoublonnage grossier). */46export function lieuxDepuis(pois: Poi[], cm: CommercesNearby | null, gaz: GazNearby | null): Lieu[] {47  const out: Lieu[] = [];48  const seen = new Set<string>();49  const push = (x: Lieu) => {50    const k = `${x.cat}|${x.name.toLowerCase()}|${x.lat.toFixed(4)}|${x.lng.toFixed(4)}`;51    if (seen.has(k)) return;52    seen.add(k); out.push(x);53  };54  for (const c of [...(cm?.transit ?? []), ...(cm?.commerces ?? [])])55    if (c.lat != null && c.lng != null)56      push({ id: `cm-${c.id}-${c.dist_m}`, cat: COMMERCE_CAT[c.id] ?? "commerce", name: c.commerce || c.nom,57             sub: c.nom !== c.commerce ? c.nom : c.adresse, lat: c.lat, lng: c.lng, dist_m: c.dist_m });58  for (const p of pois)59    if (p.lat != null && p.lng != null)60      push({ id: `poi-${p.cat}`, cat: POI_CAT[p.cat] ?? "commerce", name: p.name, lat: p.lat, lng: p.lng, dist_m: p.dist_m });61  for (const s of gaz?.stations ?? [])62    if (s.lat != null && s.lng != null)63      push({ id: `gaz-${s.lat}-${s.lng}`, cat: "essence", name: s.nom,64             sub: s.regulier != null ? `${s.regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢/L` : s.adresse,65             lat: s.lat, lng: s.lng, dist_m: s.dist_m });66  return out;67}6869export default function InteractiveMap({ l, lieux, loadingLieux }: { l: Listing; lieux: Lieu[]; loadingLieux: boolean }) {70  const [on, setOn] = useState<Set<string>>(new Set());71  const [visible, setVisible] = useState(false);72  const counts = useMemo(() => {73    const c: Record<string, number> = {};74    for (const x of lieux) c[x.cat] = (c[x.cat] ?? 0) + 1;75    return c;76  }, [lieux]);77  const actifs = useMemo(() => lieux.filter((x) => on.has(x.cat)), [lieux, on]);7879  // la carte (Mapbox) ne se charge qu'à l'approche de la section80  useEffect(() => {81    const el = document.getElementById("carte");82    if (!el) return;83    if (!("IntersectionObserver" in window)) { setVisible(true); return; }84    const io = new IntersectionObserver((e) => { if (e.some((x) => x.isIntersecting)) { setVisible(true); io.disconnect(); } },85                                        { rootMargin: "400px 0px" });86    io.observe(el);87    return () => io.disconnect();88  }, []);8990  if (l.lat == null || l.lng == null) return null;91  const toggle = (k: string) => setOn((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n; });9293  return (94    <SectionCard id="carte" title="Carte" icon={<IcoMapPin size={18} />}95                 sub="Immeuble de l'annonce en orange · position selon l'adresse géocodée (Adresses Québec)">96      <div className="lk-chips" role="group" aria-label="Lieux à afficher sur la carte">97        {CATEGORIES.map((c) => {98          const n = counts[c.key] ?? 0;99          return (100            <button type="button" key={c.key} className={`lk-chip ${on.has(c.key) ? "on" : ""}`}101                    style={{ "--c": c.color } as React.CSSProperties} disabled={n === 0}102                    aria-pressed={on.has(c.key)} onClick={() => toggle(c.key)}103                    title={n === 0 ? (loadingLieux ? "Chargement…" : "Aucune position connue pour ce secteur") : undefined}>104              <i className="dot" aria-hidden="true" />{c.label}{n > 0 && <small>{n}</small>}105            </button>106          );107        })}108      </div>109      <div className="lk-map" role="img" aria-label={`Carte 3D — ${l.address || l.title}`}>110        {visible ? (111          <Suspense fallback={<Skeleton className="lk-map-skel" h="100%" r={0} />}>112            <MapInner l={l} lieux={actifs} categories={CATEGORIES} />113          </Suspense>114        ) : <Skeleton className="lk-map-skel" h="100%" r={0} />}115        <span className="lk-map-legend" aria-hidden="true"><i /> Immeuble de l'annonce</span>116      </div>117      {actifs.length > 0 && (118        <p className="lk-map-hint">119          {actifs.length} lieu{actifs.length > 1 ? "x" : ""} affiché{actifs.length > 1 ? "s" : ""} · le plus proche à {fmtDist(Math.min(...actifs.map((x) => x.dist_m)))}120        </p>121      )}122    </SectionCard>123  );124}125