Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/MapInner.tsx : partie Mapbox de la carte de la fiche (chargée5// paresseusement) — KaSpotlightMap (Ka Maps, thème Immo-Ka) + lignes de6// métro + couche des lieux filtrés (cercles colorés par catégorie +7// étiquettes) + caméra qui englobe les lieux affichés.8// -----------------------------------------------------------------------------9import { useEffect, useMemo } from "react";10import "mapbox-gl/dist/mapbox-gl.css";11import "@groupe-ka/ka-maps/styles.css";12import type { MapProperty } from "@groupe-ka/ka-maps";13import { KaBrandBadge, KaSpotlightMap, useKaMap } from "@groupe-ka/ka-maps/react";14import type mapboxgl from "mapbox-gl";15import type { Listing } from "../api";16import MetroLignes from "../components/MetroLignes";17import { immoKaMapTheme } from "../kamaps/theme";18import { MAPBOX_TOKEN } from "../kamaps/config";19import type { Categorie, Lieu } from "./InteractiveMap";2021const SRC = "ik-lieux";22/** Cerise signal Immo-Ka — même langage que l'accent de marque. */23export const BUILDING_RED = "#e23744";2425function LieuxLayer({ lieux, categories, center }: { lieux: Lieu[]; categories: Categorie[]; center: [number, number] }) {26 const ka = useKaMap();27 const couleurs = useMemo(() => {28 const m: unknown[] = ["match", ["get", "cat"]];29 for (const c of categories) m.push(c.key, c.color);30 m.push("#666");31 return m;32 }, [categories]);3334 useEffect(() => {35 if (!ka) return;36 const map = (ka as unknown as { map: mapboxgl.Map }).map;37 if (!map) return;38 const data = {39 type: "FeatureCollection" as const,40 features: lieux.map((x) => ({41 type: "Feature" as const, properties: { cat: x.cat, name: x.name },42 geometry: { type: "Point" as const, coordinates: [x.lng, x.lat] },43 })),44 };45 const ensure = () => {46 const src = map.getSource(SRC) as mapboxgl.GeoJSONSource | undefined;47 if (src) { src.setData(data); return; }48 map.addSource(SRC, { type: "geojson", data });49 map.addLayer({50 id: `${SRC}-halo`, type: "circle", source: SRC,51 paint: { "circle-radius": 9, "circle-color": "#ffffff", "circle-opacity": 0.95 },52 });53 map.addLayer({54 id: `${SRC}-dot`, type: "circle", source: SRC,55 paint: { "circle-radius": 6, "circle-color": couleurs as mapboxgl.ExpressionSpecification },56 });57 map.addLayer({58 id: `${SRC}-lbl`, type: "symbol", source: SRC,59 layout: {60 "text-field": ["get", "name"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top",61 "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"], "text-optional": true,62 },63 paint: { "text-color": "#141814", "text-halo-color": "#ffffff", "text-halo-width": 1.4 },64 });65 };66 const apply = () => { try { ensure(); } catch { /* style pas prêt */ } };67 if (map.isStyleLoaded()) apply();68 map.on("style.load", apply);69 map.on("load", apply);70 // caméra : englober les lieux + le bâtiment ; sans lieu → retour sur le bâtiment71 if (lieux.length > 0) {72 let w = center[0], e = center[0], s = center[1], n = center[1];73 const proches = lieux.filter((x) => x.dist_m <= 2000);74 for (const x of proches.length ? proches : lieux) { w = Math.min(w, x.lng); e = Math.max(e, x.lng); s = Math.min(s, x.lat); n = Math.max(n, x.lat); }75 map.fitBounds([[w, s], [e, n]], { padding: { top: 60, bottom: 40, left: 40, right: 40 }, pitch: 30, maxZoom: 16, duration: 700 });76 } else {77 map.easeTo({ center, zoom: 17, pitch: 62, duration: 700 });78 }79 return () => { map.off("style.load", apply); map.off("load", apply); };80 }, [ka, lieux, couleurs, center]);8182 useEffect(() => () => {83 if (!ka) return;84 const map = (ka as unknown as { map: mapboxgl.Map }).map;85 try {86 for (const id of [`${SRC}-lbl`, `${SRC}-dot`, `${SRC}-halo`]) if (map.getLayer(id)) map.removeLayer(id);87 if (map.getSource(SRC)) map.removeSource(SRC);88 } catch { /* carte détruite */ }89 }, [ka]);90 return null;91}9293export default function MapInner({ l, lieux, categories, deal }: { l: Listing; lieux: Lieu[]; categories: Categorie[]; deal: boolean }) {94 const property = useMemo<MapProperty>(() => ({95 id: l.uid, appSource: "immo-ka", latitude: l.lat as number, longitude: l.lng as number,96 kind: "listing", listingType: "sale", price: l.price ?? undefined,97 propertyType: l.property_type || undefined, address: l.address || l.title || undefined,98 city: l.city || undefined, thumbnailUrl: l.images?.[0], highlight: deal,99 }), [l.uid, l.lat, l.lng, l.price, l.property_type, l.address, l.title, l.city, l.images, deal]);100 const center = useMemo<[number, number]>(() => [l.lng as number, l.lat as number], [l.lat, l.lng]);101102 return (103 <KaSpotlightMap theme={immoKaMapTheme} mapboxToken={MAPBOX_TOKEN} property={property} buildingColor={BUILDING_RED}>104 <KaBrandBadge />105 <MetroLignes />106 <LieuxLayer lieux={lieux} categories={categories} center={center} />107 </KaSpotlightMap>108 );109}110