SPB Git

spb/lou-ka Public

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

HTML 99.7%

Mission 4 — géocodage Nominatim + carte interactive MapLibre GL

Géocodage (louka/geocode.py) :
- Nominatim 1 req/s, User-Agent identifié, cache SQLite par immeuble
  (adresse normalisée sans n° d'unité) — une adresse n'est géocodée qu'une fois
- nettoyages QC : préfixe d'unité « 101-2905 », « Montréal - Laval »,
  cardinales abrégées ; repli civique+rue en 2e tentative
- validation bounding-box (Québec/Lévis resserrée, garde-fou provincial),
  flag geocode_failed, ré-essai des échecs après 30 jours
- intégré à la boucle watch (nouvelles adresses géocodées à chaque sync)
- finalize() rejette aussi les coordonnées invalides fournies par les sources
  (annonce de Limoilou qui pointait au Kazakhstan)

Carte (frontend) :
- /api/listings.geojson : GeoJSON filtré (mêmes filtres que /api/listings)
- MapView MapLibre GL : chargement paresseux (254 ko gzip hors du bundle
  principal), clustering natif, marqueurs-prix, popup fiche (photo, prix,
  type, dispo, gestionnaire, lien), synchro avec les filtres, fitBounds
- bascule Liste/Carte : côte à côte sur desktop, plein écran sur mobile
- tuiles vectorielles OpenFreeMap (positron), style Lou-Ka (encre + lime)
- worker MapLibre v6 copié par plugin Vite (imports relatifs préservés)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 2 days ago (Aug 8, 2026) parent 4b00ba8

Showing 12 changed files with +819 and −8

modified frontend/package-lock.json +209 −0
@@ -8,6 +8,7 @@
8 8 "name": "lou-ka-frontend",
9 9 "version": "1.0.0",
10 10 "dependencies": {
11 + "maplibre-gl": "^6.2.0",
11 12 "react": "^18.3.1",
12 13 "react-dom": "^18.3.1",
13 14 "react-router-dom": "^6.26.0"
@@ -743,6 +744,92 @@
743 744 "@jridgewell/sourcemap-codec": "^1.4.14"
744 745 }
745 746 },
747 + "node_modules/@mapbox/jsonlint-lines-primitives": {
748 + "version": "2.0.3",
749 + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
750 + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
751 + "license": "MIT",
752 + "engines": {
753 + "node": ">= 22"
754 + }
755 + },
756 + "node_modules/@mapbox/point-geometry": {
757 + "version": "1.1.0",
758 + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
759 + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
760 + "license": "ISC"
761 + },
762 + "node_modules/@mapbox/tiny-sdf": {
763 + "version": "2.2.0",
764 + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
765 + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
766 + "license": "BSD-2-Clause"
767 + },
768 + "node_modules/@mapbox/unitbezier": {
769 + "version": "1.0.0",
770 + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
771 + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
772 + "license": "BSD-2-Clause"
773 + },
774 + "node_modules/@mapbox/vector-tile": {
775 + "version": "3.0.0",
776 + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
777 + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
778 + "license": "BSD-3-Clause",
779 + "dependencies": {
780 + "@mapbox/point-geometry": "~1.1.0",
781 + "@types/geojson": "^7946.0.16",
782 + "pbf": "^5.0.0"
783 + }
784 + },
785 + "node_modules/@maplibre/geojson-vt": {
786 + "version": "6.1.1",
787 + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
788 + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
789 + "license": "ISC",
790 + "dependencies": {
791 + "kdbush": "^4.1.0"
792 + }
793 + },
794 + "node_modules/@maplibre/maplibre-gl-style-spec": {
795 + "version": "26.2.1",
796 + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz",
797 + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==",
798 + "license": "ISC",
799 + "dependencies": {
800 + "@mapbox/jsonlint-lines-primitives": "^2.0.3",
801 + "@mapbox/unitbezier": "^1.0.0",
802 + "json-stringify-pretty-compact": "^4.0.0",
803 + "minimist": "^1.2.8",
804 + "quickselect": "^3.0.0",
805 + "tinyqueue": "^3.0.0"
806 + },
807 + "bin": {
808 + "gl-style-format": "dist/gl-style-format.mjs",
809 + "gl-style-migrate": "dist/gl-style-migrate.mjs",
810 + "gl-style-validate": "dist/gl-style-validate.mjs"
811 + }
812 + },
813 + "node_modules/@maplibre/mlt": {
814 + "version": "1.1.12",
815 + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
816 + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
817 + "license": "(MIT OR Apache-2.0)",
818 + "dependencies": {
819 + "@mapbox/point-geometry": "^1.1.0"
820 + }
821 + },
822 + "node_modules/@maplibre/vt-pbf": {
823 + "version": "4.3.2",
824 + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
825 + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
826 + "license": "MIT",
827 + "dependencies": {
828 + "@mapbox/point-geometry": "^1.1.0",
829 + "@types/geojson": "^7946.0.16",
830 + "pbf": "^5.1.0"
831 + }
832 + },
746 833 "node_modules/@napi-rs/lzma-linux-x64-gnu": {
747 834 "version": "1.5.1",
748 835 "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1220,6 +1307,12 @@
1220 1307 "dev": true,
1221 1308 "license": "MIT"
1222 1309 },
1310 + "node_modules/@types/geojson": {
1311 + "version": "7946.0.16",
1312 + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1313 + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1314 + "license": "MIT"
1315 + },
1223 1316 "node_modules/@types/prop-types": {
1224 1317 "version": "15.7.15",
1225 1318 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1369,6 +1462,12 @@
1369 1462 }
1370 1463 }
1371 1464 },
1465 + "node_modules/earcut": {
1466 + "version": "3.2.3",
1467 + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
1468 + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
1469 + "license": "ISC"
1470 + },
1372 1471 "node_modules/electron-to-chromium": {
1373 1472 "version": "1.5.402",
1374 1473 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
@@ -1450,6 +1549,12 @@
1450 1549 "node": ">=6.9.0"
1451 1550 }
1452 1551 },
1552 + "node_modules/gl-matrix": {
1553 + "version": "3.4.4",
1554 + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
1555 + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
1556 + "license": "MIT"
1557 + },
1453 1558 "node_modules/js-tokens": {
1454 1559 "version": "4.0.0",
1455 1560 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1469,6 +1574,12 @@
1469 1574 "node": ">=6"
1470 1575 }
1471 1576 },
1577 + "node_modules/json-stringify-pretty-compact": {
1578 + "version": "4.0.0",
1579 + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
1580 + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
1581 + "license": "MIT"
1582 + },
1472 1583 "node_modules/json5": {
1473 1584 "version": "2.2.3",
1474 1585 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1482,6 +1593,12 @@
1482 1593 "node": ">=6"
1483 1594 }
1484 1595 },
1596 + "node_modules/kdbush": {
1597 + "version": "4.1.0",
1598 + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
1599 + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
1600 + "license": "ISC"
1601 + },
1485 1602 "node_modules/loose-envify": {
1486 1603 "version": "1.4.0",
1487 1604 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1504,6 +1621,47 @@
1504 1621 "yallist": "^3.0.2"
1505 1622 }
1506 1623 },
1624 + "node_modules/maplibre-gl": {
1625 + "version": "6.2.0",
1626 + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.2.0.tgz",
1627 + "integrity": "sha512-PaNYtxWmYgIdDHshXsnU3Pho+H9IPme9H6dTjZbFWNazi+Q5QgKgIlu2GiSGVtOZ77/Fz3n5/1/kGM6ETzu0Lg==",
1628 + "license": "BSD-3-Clause",
1629 + "dependencies": {
1630 + "@mapbox/point-geometry": "^1.1.0",
1631 + "@mapbox/tiny-sdf": "^2.2.0",
1632 + "@mapbox/unitbezier": "^1.0.0",
1633 + "@mapbox/vector-tile": "^3.0.0",
1634 + "@maplibre/geojson-vt": "^6.1.1",
1635 + "@maplibre/maplibre-gl-style-spec": "^26.2.1",
1636 + "@maplibre/mlt": "^1.1.12",
1637 + "@maplibre/vt-pbf": "^4.3.2",
1638 + "@types/geojson": "^7946.0.16",
1639 + "earcut": "^3.2.3",
1640 + "gl-matrix": "^3.4.4",
1641 + "kdbush": "^4.1.0",
1642 + "murmurhash-js": "^1.0.0",
1643 + "pbf": "^5.1.2",
1644 + "potpack": "^2.1.0",
1645 + "quickselect": "^3.0.0",
1646 + "tinyqueue": "^3.0.0"
1647 + },
1648 + "engines": {
1649 + "node": ">=16.14.0",
1650 + "npm": ">=8.1.0"
1651 + },
1652 + "funding": {
1653 + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
1654 + }
1655 + },
1656 + "node_modules/minimist": {
1657 + "version": "1.2.8",
1658 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1659 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1660 + "license": "MIT",
1661 + "funding": {
1662 + "url": "https://github.com/sponsors/ljharb"
1663 + }
1664 + },
1507 1665 "node_modules/ms": {
1508 1666 "version": "2.1.3",
1509 1667 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1511,6 +1669,12 @@
1511 1669 "dev": true,
1512 1670 "license": "MIT"
1513 1671 },
1672 + "node_modules/murmurhash-js": {
1673 + "version": "1.0.0",
1674 + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
1675 + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
1676 + "license": "MIT"
1677 + },
1514 1678 "node_modules/nanoid": {
1515 1679 "version": "3.3.17",
1516 1680 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
@@ -1540,6 +1704,18 @@
1540 1704 "node": ">=18"
1541 1705 }
1542 1706 },
1707 + "node_modules/pbf": {
1708 + "version": "5.1.2",
1709 + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
1710 + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
1711 + "license": "BSD-3-Clause",
1712 + "dependencies": {
1713 + "resolve-protobuf-schema": "^2.1.0"
1714 + },
1715 + "bin": {
1716 + "pbf": "bin/pbf"
1717 + }
1718 + },
1543 1719 "node_modules/picocolors": {
1544 1720 "version": "1.1.1",
1545 1721 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1576,6 +1752,24 @@
1576 1752 "node": "^10 || ^12 || >=14"
1577 1753 }
1578 1754 },
1755 + "node_modules/potpack": {
1756 + "version": "2.1.0",
1757 + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
1758 + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
1759 + "license": "ISC"
1760 + },
1761 + "node_modules/protocol-buffers-schema": {
1762 + "version": "3.6.1",
1763 + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
1764 + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
1765 + "license": "MIT"
1766 + },
1767 + "node_modules/quickselect": {
1768 + "version": "3.0.0",
1769 + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
1770 + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
1771 + "license": "ISC"
1772 + },
1579 1773 "node_modules/react": {
1580 1774 "version": "18.3.1",
1581 1775 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1643,6 +1837,15 @@
1643 1837 "react-dom": ">=16.8"
1644 1838 }
1645 1839 },
1840 + "node_modules/resolve-protobuf-schema": {
1841 + "version": "2.1.0",
1842 + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
1843 + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
1844 + "license": "MIT",
1845 + "dependencies": {
1846 + "protocol-buffers-schema": "^3.3.1"
1847 + }
1848 + },
1646 1849 "node_modules/rollup": {
1647 1850 "version": "4.62.4",
1648 1851 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1718,6 +1921,12 @@
1718 1921 "node": ">=0.10.0"
1719 1922 }
1720 1923 },
1924 + "node_modules/tinyqueue": {
1925 + "version": "3.0.0",
1926 + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
1927 + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
1928 + "license": "ISC"
1929 + },
1721 1930 "node_modules/typescript": {
1722 1931 "version": "5.9.3",
1723 1932 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
modified frontend/package.json +1 −0
@@ -10,6 +10,7 @@
10 10 "preview": "vite preview"
11 11 },
12 12 "dependencies": {
13 + "maplibre-gl": "^6.2.0",
13 14 "react": "^18.3.1",
14 15 "react-dom": "^18.3.1",
15 16 "react-router-dom": "^6.26.0"
added frontend/src/components/MapView.tsx +205 −0
@@ -0,0 +1,205 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/MapView.tsx : carte interactive MapLibre GL
5 +// · chargée paresseusement (React.lazy) — le bundle carte ne pèse rien
6 +// tant que l'utilisateur reste en vue liste
7 +// · données GeoJSON (/api/listings.geojson) synchronisées avec les filtres
8 +// · clustering natif, marqueurs-prix, fiche popup, tuiles OpenFreeMap
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useRef } from "react";
11 +import { useNavigate } from "react-router-dom";
12 +import * as maplibregl from "maplibre-gl";
13 +import "maplibre-gl/dist/maplibre-gl.css";
14 +import { ListingFilters, sourceName } from "../api";
15 +
16 +// MapLibre v6 : worker en fichier séparé. En production il est copié
17 +// (avec son module partagé) dans dist/maplibre/ par le plugin Vite
18 +// « louka-copy-maplibre-worker » ; en dev, la résolution par défaut
19 +// (node_modules servi par Vite) fonctionne déjà.
20 +if (import.meta.env.PROD) {
21 + maplibregl.setWorkerUrl("/maplibre/maplibre-gl-worker.mjs");
22 +}
23 +
24 +const STYLE_URL = "https://tiles.openfreemap.org/styles/positron";
25 +const QUEBEC_CENTER: [number, number] = [-71.25, 46.82];
26 +
27 +function geojsonUrl(f: ListingFilters): string {
28 + const params = new URLSearchParams();
29 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
30 + return `/api/listings.geojson?${params}`;
31 +}
32 +
33 +const fmtDispo = (iso: string | null): string =>
34 + !iso ? "" : iso === "now" ? "Libre maintenant"
35 + : `Dispo ${new Date(iso + "T12:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" })}`;
36 +
37 +function popupHTML(p: Record<string, unknown>): string {
38 + const prix = p.price != null
39 + ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
40 + : (p.price_label as string) || "Prix sur demande";
41 + const img = p.image
42 + ? `<img src="${p.image}" alt="" loading="lazy"/>`
43 + : `<div class="mv-noimg">🏠</div>`;
44 + const meta = [p.unit_type, p.area_sqft ? `${Math.round(Number(p.area_sqft))} pi²` : "",
45 + fmtDispo(p.availability_date as string | null)]
46 + .filter(Boolean).join(" · ");
47 + return `
48 + <div class="mv-pop">
49 + ${img}
50 + <div class="mv-pop-body">
51 + <div class="mv-pop-price">${prix}<small>${p.price != null ? " /mois" : ""}</small></div>
52 + <div class="mv-pop-title">${p.title ?? ""}</div>
53 + <div class="mv-pop-meta">${meta}</div>
54 + <div class="mv-pop-src">${sourceName(String(p.source ?? ""))}</div>
55 + <a class="mv-pop-cta" href="/logement/${encodeURIComponent(String(p.uid))}">Voir la fiche →</a>
56 + </div>
57 + </div>`;
58 +}
59 +
60 +export default function MapView({ filters }: { filters: ListingFilters }) {
61 + const div = useRef<HTMLDivElement>(null);
62 + const mapRef = useRef<maplibregl.Map | null>(null);
63 + const loadedRef = useRef(false);
64 + const navigate = useNavigate();
65 +
66 + // création de la carte (une seule fois)
67 + useEffect(() => {
68 + if (!div.current || mapRef.current) return;
69 + const map = new maplibregl.Map({
70 + container: div.current,
71 + style: STYLE_URL,
72 + center: QUEBEC_CENTER,
73 + zoom: 11,
74 + attributionControl: { compact: true },
75 + });
76 + mapRef.current = map;
77 + (window as unknown as { _loukaMap?: maplibregl.Map })._loukaMap = map; // hook de test/débogage
78 + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
79 + map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: false }), "top-right");
80 + map.touchZoomRotate.disableRotation();
81 +
82 + map.on("load", () => {
83 + map.addSource("logements", {
84 + type: "geojson",
85 + data: { type: "FeatureCollection", features: [] },
86 + cluster: true,
87 + clusterMaxZoom: 15,
88 + clusterRadius: 46,
89 + });
90 +
91 + // grappes : cercle encre + halo lime, compte au centre
92 + map.addLayer({
93 + id: "clusters", type: "circle", source: "logements",
94 + filter: ["has", "point_count"],
95 + paint: {
96 + "circle-color": "#141814",
97 + "circle-radius": ["step", ["get", "point_count"], 15, 10, 19, 50, 24, 200, 30],
98 + "circle-stroke-width": 3,
99 + "circle-stroke-color": "#d9f26b",
100 + },
101 + });
102 + map.addLayer({
103 + id: "cluster-count", type: "symbol", source: "logements",
104 + filter: ["has", "point_count"],
105 + layout: {
106 + "text-field": ["get", "point_count_abbreviated"],
107 + "text-font": ["Noto Sans Bold"],
108 + "text-size": 13,
109 + },
110 + paint: { "text-color": "#d9f26b" },
111 + });
112 +
113 + // marqueurs individuels : point vert + étiquette-prix au-dessus
114 + map.addLayer({
115 + id: "point", type: "circle", source: "logements",
116 + filter: ["!", ["has", "point_count"]],
117 + paint: {
118 + "circle-color": "#1c5c41",
119 + "circle-radius": 5,
120 + "circle-stroke-width": 1.5,
121 + "circle-stroke-color": "#f5f3ee",
122 + },
123 + });
124 + map.addLayer({
125 + id: "point-price", type: "symbol", source: "logements",
126 + filter: ["!", ["has", "point_count"]],
127 + layout: {
128 + "text-field": [
129 + "case",
130 + ["!=", ["get", "price"], null],
131 + ["concat", ["number-format", ["get", "price"], { locale: "fr-CA", "max-fraction-digits": 0 }], " $"],
132 + "?",
133 + ],
134 + "text-font": ["Noto Sans Bold"],
135 + "text-size": 12,
136 + "text-offset": [0, -1.1],
137 + "text-allow-overlap": false,
138 + "text-optional": true,
139 + },
140 + paint: {
141 + "text-color": "#141814",
142 + "text-halo-color": "#d9f26b",
143 + "text-halo-width": 2,
144 + },
145 + });
146 +
147 + // interactions
148 + map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => {
149 + const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0];
150 + const src = map.getSource("logements") as maplibregl.GeoJSONSource;
151 + const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id);
152 + map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });
153 + });
154 + const openPopup = (e: maplibregl.MapLayerMouseEvent) => {
155 + const f = e.features?.[0];
156 + if (!f) return;
157 + const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number];
158 + const pop = new maplibregl.Popup({ offset: 14, maxWidth: "290px", closeButton: true })
159 + .setLngLat(coords).setHTML(popupHTML(f.properties as Record<string, unknown>)).addTo(map);
160 + // navigation SPA (sans rechargement) sur le lien de la fiche
161 + pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => {
162 + ev.preventDefault();
163 + navigate(`/logement/${encodeURIComponent(String((f.properties as any).uid))}`);
164 + });
165 + };
166 + map.on("click", "point", openPopup);
167 + map.on("click", "point-price", openPopup);
168 + for (const layer of ["clusters", "point", "point-price"]) {
169 + map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; });
170 + map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; });
171 + }
172 + loadedRef.current = true;
173 + });
174 +
175 + return () => { map.remove(); mapRef.current = null; loadedRef.current = false; };
176 + }, [navigate]);
177 +
178 + // synchronisation avec les filtres
179 + useEffect(() => {
180 + const map = mapRef.current;
181 + if (!map) return;
182 + let cancelled = false;
183 + const apply = async () => {
184 + try {
185 + const res = await fetch(geojsonUrl(filters));
186 + const data = (await res.json()) as GeoJSON.FeatureCollection;
187 + if (cancelled || !mapRef.current) return;
188 + (map.getSource("logements") as maplibregl.GeoJSONSource | undefined)?.setData(data);
189 + // cadrer sur les résultats (avec limites raisonnables)
190 + if (data.features.length > 0) {
191 + const b = new maplibregl.LngLatBounds();
192 + for (const f of data.features)
193 + b.extend((f.geometry as GeoJSON.Point).coordinates as [number, number]);
194 + map.fitBounds(b, { padding: 56, maxZoom: 15, duration: 500 });
195 + }
196 + } catch { /* réseau : la carte garde ses données précédentes */ }
197 + };
198 + if (loadedRef.current) apply();
199 + else map.once("load", () => apply()); // la source est créée par le
200 + // gestionnaire « load » de l'effet de création, enregistré avant celui-ci
201 + return () => { cancelled = true; };
202 + }, [filters]);
203 +
204 + return <div ref={div} className="mapview" role="application" aria-label="Carte des logements" />;
205 +}
modified frontend/src/pages/Home.tsx +46 −6
@@ -3,15 +3,18 @@
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // pages/Home.tsx : accueil — héro, statistiques, filtres, grille d'annonces
5 5 // -----------------------------------------------------------------------------
6 import { useEffect, useMemo, useState } from "react";
6 +import { Suspense, lazy, useEffect, useMemo, useState } from "react";
7 7 import { useSearchParams } from "react-router-dom";
8 8 import {
9 Facets, Listing, Stats,
9 + Facets, Listing, ListingFilters, Stats,
10 10 fetchFacets, fetchListings, fetchSources, fetchStats,
11 11 registerSourceNames, sourceName,
12 12 } from "../api";
13 13 import ListingCard from "../components/ListingCard";
14 14
15 +// La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre
16 +const MapView = lazy(() => import("../components/MapView"));
17 +
15 18 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
16 19
17 20 export default function Home() {
@@ -31,6 +34,12 @@ export default function Home() {
31 34 // feuille de filtres mobile (bottom sheet)
32 35 const [sheetOpen, setSheetOpen] = useState(false);
33 36 const activeFilters = [q, city, source, priceMax, unitType].filter(Boolean).length;
37 + // vue liste ou carte (mémorisée dans l'URL : /?view=carte)
38 + const [view, setView] = useState<"liste" | "carte">(
39 + params.get("view") === "carte" ? "carte" : "liste");
40 + const filters: ListingFilters = useMemo(
41 + () => ({ q, city, source, price_max: priceMax, unit_type: unitType }),
42 + [q, city, source, priceMax, unitType]);
34 43
35 44 useEffect(() => {
36 45 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -173,7 +182,25 @@ export default function Home() {
173 182
174 183 <div className="results-head">
175 184 <h2>Logements disponibles</h2>
176 {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
185 + <div className="results-tools">
186 + {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
187 + <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
188 + <button
189 + role="tab" aria-selected={view === "liste"}
190 + className={view === "liste" ? "on" : ""}
191 + onClick={() => setView("liste")}
192 + >
193 + ☰ Liste
194 + </button>
195 + <button
196 + role="tab" aria-selected={view === "carte"}
197 + className={view === "carte" ? "on" : ""}
198 + onClick={() => setView("carte")}
199 + >
200 + ◈ Carte
201 + </button>
202 + </div>
203 + </div>
177 204 </div>
178 205
179 206 {error && (
@@ -187,7 +214,7 @@ export default function Home() {
187 214 </div>
188 215 )}
189 216
190 {!error && listings === null && (
217 + {!error && view === "liste" && listings === null && (
191 218 <div className="grid" aria-busy="true">
192 219 {Array.from({ length: 8 }).map((_, i) => (
193 220 <div className="skel" key={i}>
@@ -199,7 +226,7 @@ export default function Home() {
199 226 </div>
200 227 )}
201 228
202 {!error && listings !== null && listings.length === 0 && (
229 + {!error && view === "liste" && listings !== null && listings.length === 0 && (
203 230 <div className="notice">
204 231 <div className="big">🔍</div>
205 232 <h2>Aucun logement ne correspond</h2>
@@ -207,7 +234,20 @@ export default function Home() {
207 234 </div>
208 235 )}
209 236
210 {!error && listings !== null && listings.length > 0 && (
237 + {!error && view === "carte" && (
238 + <div className="map-split">
239 + <div className="map-list" aria-label="Résultats en liste">
240 + {(listings ?? []).map((l) => (
241 + <ListingCard key={l.uid} l={l} />
242 + ))}
243 + </div>
244 + <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
245 + <MapView filters={filters} />
246 + </Suspense>
247 + </div>
248 + )}
249 +
250 + {!error && view === "liste" && listings !== null && listings.length > 0 && (
211 251 <div className="grid">
212 252 {listings.map((l) => (
213 253 <ListingCard key={l.uid} l={l} />
modified frontend/src/styles.css +68 −0
@@ -503,3 +503,71 @@ img { display: block; }
503 503 .footer b { color: var(--paper); }
504 504 .footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 720px; }
505 505 .footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; }
506 +
507 +/* --- Carte interactive (mission 4) --------------------------------------- */
508 +.results-tools { display: flex; align-items: center; gap: 14px; }
509 +.view-toggle {
510 + display: inline-flex; border: 1.5px solid var(--line-strong);
511 + border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);
512 + box-shadow: var(--shadow-flat);
513 +}
514 +.view-toggle button {
515 + border: 0; background: transparent; padding: 7px 14px; cursor: pointer;
516 + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
517 +}
518 +.view-toggle button + button { border-left: 1.5px solid var(--line-strong); }
519 +.view-toggle button.on { background: var(--ink); color: var(--lime); }
520 +
521 +.map-split {
522 + display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px;
523 + height: calc(100dvh - 170px); min-height: 420px;
524 +}
525 +.map-list {
526 + overflow-y: auto; display: flex; flex-direction: column; gap: 14px;
527 + padding-right: 4px; scrollbar-width: thin;
528 +}
529 +.map-list .card { margin: 0; flex: 0 0 auto; }
530 +.mapview {
531 + width: 100%; height: 100%; border: 1.5px solid var(--line-strong);
532 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
533 + overflow: hidden; background: var(--surface-2);
534 +}
535 +.map-loading {
536 + display: flex; align-items: center; justify-content: center;
537 + color: var(--ink-3); font-family: var(--font-mono); font-size: 13px;
538 +}
539 +
540 +/* mobile d'abord : carte plein écran, liste masquée (bascule via l'onglet) */
541 +@media (max-width: 780px) {
542 + .map-split { grid-template-columns: 1fr; height: calc(100dvh - 210px); }
543 + .map-list { display: none; }
544 + .results-tools { width: 100%; justify-content: space-between; }
545 +}
546 +
547 +/* popup MapLibre au style Lou-Ka */
548 +.maplibregl-popup-content {
549 + padding: 0; border-radius: var(--r-card); overflow: hidden;
550 + border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off);
551 + font-family: var(--font-body);
552 +}
553 +.maplibregl-popup-close-button {
554 + font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2;
555 + text-shadow: 0 0 4px rgba(20, 24, 20, 0.8);
556 +}
557 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
558 +.mv-noimg { display: flex; align-items: center; justify-content: center;
559 + font-size: 34px; background: var(--surface-2); }
560 +.mv-pop-body { padding: 10px 12px 12px; }
561 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
562 +.mv-pop-price small { font-weight: 400; color: var(--ink-3); font-size: 12px; }
563 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px;
564 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
565 +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
566 +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3);
567 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
568 +.mv-pop-cta {
569 + display: block; margin-top: 10px; padding: 8px 10px; text-align: center;
570 + background: var(--ink); color: var(--lime); border-radius: var(--r-ctl);
571 + font-weight: 600; font-size: 13px;
572 +}
573 +.mv-pop-cta:hover { background: var(--green-deep); }
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
modified frontend/vite.config.ts +18 −2
@@ -3,11 +3,27 @@
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5 5 // -----------------------------------------------------------------------------
6 import { defineConfig } from "vite";
6 +import { copyFileSync, mkdirSync } from "node:fs";
7 +import { defineConfig, type Plugin } from "vite";
7 8 import react from "@vitejs/plugin-react";
8 9
10 +// MapLibre v6 charge son worker (module ES + import relatif d'un module
11 +// partagé) depuis un fichier séparé : on copie les deux fichiers TELS QUELS
12 +// (non hachés, même dossier) pour que l'import relatif du worker fonctionne.
13 +// MapView.tsx pointe dessus via maplibregl.setWorkerUrl en production.
14 +const MAPLIBRE_WORKER_FILES = ["maplibre-gl-worker.mjs", "maplibre-gl-shared.mjs"];
15 +const copyMaplibreWorker = (): Plugin => ({
16 + name: "louka-copy-maplibre-worker",
17 + closeBundle() {
18 + mkdirSync("dist/maplibre", { recursive: true });
19 + for (const f of MAPLIBRE_WORKER_FILES) {
20 + copyFileSync(`node_modules/maplibre-gl/dist/${f}`, `dist/maplibre/${f}`);
21 + }
22 + },
23 +});
24 +
9 25 export default defineConfig({
10 plugins: [react()],
26 + plugins: [react(), copyMaplibreWorker()],
11 27 server: {
12 28 proxy: { "/api": "http://localhost:8080" },
13 29 },
added louka/geocode.py +183 −0
@@ -0,0 +1,183 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# geocode.py : géocodage des adresses via Nominatim (OpenStreetMap)
5 +# - cache persistant (table geocode_cache) : une adresse n'est géocodée
6 +# qu'une seule fois, et par immeuble (adresse normalisée), pas par annonce
7 +# - politesse : 1 requête/seconde max, User-Agent identifiable
8 +# - validation : les coordonnées doivent tomber dans la région attendue
9 +# (bounding box par ville), sinon flag geocode_failed — jamais de
10 +# coordonnées bidon
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +import time
16 +
17 +import requests
18 +
19 +from . import db
20 +from .normalize import strip_accents
21 +
22 +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
23 +USER_AGENT = "LouKaBot/1.0 (agrégateur logements Québec; +contact@spboucher.ai)"
24 +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s
25 +RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours
26 +
27 +# Bounding boxes (lat_min, lat_max, lng_min, lng_max)
28 +_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin
29 +_BBOX_SUD_QC = (45.00, 47.60, -74.50, -70.30) # garde-fou (incl. Gd Montréal)
30 +
31 +_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",
32 + "l'ancienne-lorette", "ancienne-lorette"}
33 +
34 +
35 +def _bbox_for(city: str) -> tuple[float, float, float, float]:
36 + key = strip_accents((city or "").strip().lower())
37 + return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC
38 +
39 +
40 +def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:
41 + return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]
42 +
43 +
44 +def norm_key(address: str) -> str:
45 + """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""
46 + s = strip_accents((address or "").lower())
47 + s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)
48 + s = re.sub(r"[^a-z0-9]+", " ", s)
49 + return re.sub(r"\s+", " ", s).strip()
50 +
51 +
52 +class Geocoder:
53 + def __init__(self, con) -> None:
54 + self.con = con
55 + self.session = requests.Session()
56 + self.session.headers["User-Agent"] = USER_AGENT
57 + self._last = 0.0
58 +
59 + @staticmethod
60 + def _clean(address: str) -> str:
61 + """Nettoyages qui aident Nominatim sur les adresses québécoises."""
62 + s = address.strip()
63 + # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique
64 + s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)
65 + # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu
66 + s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)
67 + # abréviations cardinales : « Rue Salaberry O » -> « Ouest »
68 + s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)
69 + s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)
70 + return s
71 +
72 + def _build_query(self, address: str, city: str) -> str:
73 + q = self._clean(address)
74 + key = strip_accents(q.lower())
75 + if city and strip_accents(city.lower()) not in key:
76 + q += f", {city}"
77 + if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
78 + q += ", Québec"
79 + return q + ", Canada"
80 +
81 + def _query_nominatim(self, q: str) -> tuple[float, float] | None:
82 + """Une requête Nominatim, throttlée."""
83 + wait = REQUEST_DELAY - (time.time() - self._last)
84 + if wait > 0:
85 + time.sleep(wait)
86 + try:
87 + resp = self.session.get(NOMINATIM_URL, params={
88 + "q": q, "format": "jsonv2", "limit": 1, "countrycodes": "ca",
89 + }, timeout=20)
90 + self._last = time.time()
91 + resp.raise_for_status()
92 + hits = resp.json()
93 + except Exception:
94 + self._last = time.time()
95 + return None
96 + if not hits:
97 + return None
98 + try:
99 + return float(hits[0]["lat"]), float(hits[0]["lon"])
100 + except (KeyError, ValueError):
101 + return None
102 +
103 + def resolve(self, address: str, city: str) -> tuple[float, float] | None:
104 + """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable."""
105 + key = norm_key(address)
106 + if not key or len(key) < 6:
107 + return None
108 + row = self.con.execute(
109 + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",
110 + (key,)).fetchone()
111 + if row is not None:
112 + if not row["failed"]:
113 + return (row["lat"], row["lng"])
114 + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
115 + return None # échec récent : ne pas marteler l'API
116 +
117 + coords = self._query_nominatim(self._build_query(address, city))
118 + if coords is None:
119 + # repli : civique + rue seulement (sans code postal ni secteur)
120 + simple = self._clean(address).split(",")[0].strip()
121 + if simple and re.match(r"^\d", simple):
122 + q = f"{simple}, {city or 'Québec'}, Québec, Canada"
123 + coords = self._query_nominatim(q)
124 + ok = coords is not None and _in_bbox(*coords, _bbox_for(city))
125 + self.con.execute(
126 + "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"
127 + " VALUES (?,?,?,?,?,?)"
128 + " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"
129 + " lng=excluded.lng, provider=excluded.provider,"
130 + " failed=excluded.failed, ts=excluded.ts",
131 + (key, coords[0] if ok else None, coords[1] if ok else None,
132 + "nominatim", 0 if ok else 1, time.time()))
133 + self.con.commit()
134 + return coords if ok else None
135 +
136 +
137 +def run(limit: int | None = None) -> dict:
138 + """Géocode les annonces actives sans coordonnées, par adresse unique.
139 +
140 + `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de
141 + cache sont gratuits et toujours appliqués).
142 + """
143 + con = db.connect()
144 + geo = Geocoder(con)
145 + rows = con.execute(
146 + """SELECT uid, address, city FROM listings
147 + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
148 + ORDER BY address""").fetchall()
149 +
150 + # regrouper par immeuble (adresse normalisée)
151 + groupes: dict[str, list] = {}
152 + for r in rows:
153 + groupes.setdefault(norm_key(r["address"]), []).append(r)
154 +
155 + done = failed = requests_made = 0
156 + for key, members in groupes.items():
157 + if not key:
158 + continue
159 + cached = con.execute(
160 + "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()
161 + if cached is None:
162 + if limit is not None and requests_made >= limit:
163 + continue
164 + requests_made += 1
165 + coords = geo.resolve(members[0]["address"], members[0]["city"])
166 + if coords:
167 + for r in members:
168 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
169 + (coords[0], coords[1], r["uid"]))
170 + done += len(members)
171 + else:
172 + # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon
173 + for r in members:
174 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
175 + (r["uid"],))
176 + failed += len(members)
177 + con.commit()
178 +
179 + con.close()
180 + stats = {"geocoded": done, "failed": failed,
181 + "unique_addresses": len(groupes), "api_requests": requests_made}
182 + print(f"[lou-ka] geocode {stats}")
183 + return stats
modified louka/ingest.py +5 −0
@@ -54,6 +54,11 @@ def watch(interval_seconds: int = 3600) -> None:
54 54 """Boucle de rafraîchissement périodique (pseudo-webhook par sondage)."""
55 55 while True:
56 56 run()
57 + try: # géocoder les nouvelles adresses (cache : quasi gratuit en régime stable)
58 + from . import geocode
59 + geocode.run(limit=120)
60 + except Exception as exc:
61 + print(f"[lou-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr)
57 62 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
58 63 time.sleep(interval_seconds)
59 64
modified louka/schema.py +7 −0
@@ -91,6 +91,13 @@ class Listing:
91 91 if self.area_sqft is not None:
92 92 break
93 93
94 + # coordonnées fournies par la source : rejeter tout point hors de la
95 + # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra
96 + # le relais sur l'adresse plutôt que d'afficher un point au Kazakhstan
97 + if self.lat is not None and self.lng is not None:
98 + if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):
99 + self.lat = self.lng = None
100 +
94 101 derived = extract_details(self.amenities, self.description, self.title)
95 102 if self.price_label and price_is_from(self.price_label):
96 103 derived["price_from"] = True
modified louka/web.py +71 −0
@@ -96,6 +96,77 @@ def list_listings(
96 96 return {"total": total, "count": len(rows), "listings": rows}
97 97
98 98
99 +@app.get("/api/listings.geojson")
100 +def listings_geojson(
101 + city: str | None = None,
102 + sector: str | None = None,
103 + unit_type: str | None = None,
104 + source: str | None = None,
105 + price_max: float | None = None,
106 + price_min: float | None = None,
107 + pets: str | None = None,
108 + furnished: int | None = None,
109 + available_by: str | None = None,
110 + area_min: float | None = None,
111 + q: str | None = None,
112 +):
113 + """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).
114 +
115 + Léger par conception : seuls les champs nécessaires aux marqueurs et aux
116 + fiches de la carte sont inclus (1 photo, pas de description).
117 + """
118 + con = db.connect()
119 + sql = ("SELECT uid, title, price, price_label, unit_type, availability_date,"
120 + " source, city, sector, area_sqft, images, lat, lng FROM listings"
121 + " WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")
122 + args: list = []
123 + if city:
124 + sql += " AND city=?"; args.append(city)
125 + if sector:
126 + sql += " AND sector LIKE ?"; args.append(f"%{sector}%")
127 + if unit_type:
128 + sql += " AND unit_type=?"; args.append(unit_type)
129 + if source:
130 + sql += " AND source=?"; args.append(source)
131 + if price_max is not None:
132 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
133 + if price_min is not None:
134 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
135 + if pets == "oui":
136 + sql += " AND pets IN ('oui','conditions')"
137 + elif pets:
138 + sql += " AND pets=?"; args.append(pets)
139 + if furnished in (0, 1):
140 + sql += " AND furnished=?"; args.append(furnished)
141 + if available_by:
142 + sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \
143 + " AND availability_date<=?))"
144 + args.append(available_by)
145 + if area_min is not None:
146 + sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)
147 + if q:
148 + sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"
149 + args += [f"%{q}%"] * 3
150 +
151 + features = []
152 + for r in con.execute(sql, args).fetchall():
153 + images = json.loads(r["images"] or "[]")
154 + features.append({
155 + "type": "Feature",
156 + "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},
157 + "properties": {
158 + "uid": r["uid"], "title": r["title"], "price": r["price"],
159 + "price_label": r["price_label"], "unit_type": r["unit_type"],
160 + "availability_date": r["availability_date"],
161 + "source": r["source"], "city": r["city"], "sector": r["sector"],
162 + "area_sqft": r["area_sqft"],
163 + "image": images[0] if images else None,
164 + },
165 + })
166 + con.close()
167 + return {"type": "FeatureCollection", "features": features}
168 +
169 +
99 170 @app.get("/api/listings/{uid}")
100 171 def get_listing(uid: str):
101 172 con = db.connect()
modified run.py +5 −0
@@ -9,6 +9,7 @@
9 9 python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 10 python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 11 python run.py record <source ...> # enregistre les fixtures de test d'une source
12 + python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
12 13 """
13 14 from __future__ import annotations
14 15
@@ -35,6 +36,10 @@ def main() -> None:
35 36 from louka import ingest
36 37 minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 60
37 38 ingest.watch(minutes * 60)
39 + elif cmd == "geocode":
40 + from louka import geocode
41 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
42 + geocode.run(limit)
38 43 elif cmd == "record":
39 44 from louka import fixtures
40 45 from louka.connectors import CONNECTORS
41 46