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>
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 | |