SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

État du nœud committé — intégration carte (frontend/src/map/, MapView, PropertyMap), geocode.py + vraiprix_local.py, MAJ sources ; gitignore data/vraiprix.db (1,6 Go) et *.bak-*

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 17, 2026) parent 5849c0a

22 changed files +1,861 −872

modified .gitignore +2 −0
@@ -10,3 +10,5 @@ node_modules/
10 10 frontend/dist/
11 11 frontend/tsconfig.tsbuildinfo
12 12 .DS_Store
13 +data/vraiprix.db
14 +*.bak-*
modified data/source_immo_agencies.json +0 −81
@@ -44,87 +44,6 @@
44 44 "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
45 45 "view": "25cb8710-6c99-47ce-b137-8c667b63062c"
46 46 },
47 − {
48 − "id": "si_ag_katia_samson",
49 − "name": "Groupe Immobilier Katia Samson",
50 − "domain": "katiasamson.com",
51 − "account": "5cd485f7-9aa3-4d32-a42a-1fbaff506ec7",
52 − "api": "ede21420-ea2b-4027-b0eb-bb6d6f2d7446",
53 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
54 − "view": "f0319c2d-b2eb-4328-b32a-f7219f769708"
55 − },
56 − {
57 − "id": "si_ag_wparwilly",
58 − "name": "W par Willy Forge Agence Immobilière",
59 − "domain": "wparwilly.com",
60 − "account": "ec195854-5706-4399-a039-342b2b2a5fc5",
61 − "api": "cc704f99-3810-40d9-8838-b8762bb74c4d",
62 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
63 − "view": "7ee864f3-1656-45c0-a102-80844bc345f4"
64 − },
65 − {
66 − "id": "si_ag_avenueimmo",
67 − "name": "Avenue Immobilier",
68 − "domain": "avenueimmo.com",
69 − "account": "abbb1cbd-d342-4ed4-8644-ac50392a8ad0",
70 − "api": "96f47605-b917-494c-972d-f5b62e6cea29",
71 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
72 − "view": "3f93275f-a8fb-442b-8604-c72e53e86bc2"
73 − },
74 − {
75 − "id": "si_ag_ferri",
76 − "name": "Services Immobiliers Ferri",
77 − "domain": "ferri.ca",
78 − "account": "8b83609e-52b5-4955-8322-020d156e9198",
79 − "api": "8a1d59c0-282b-43f4-a559-88f942a08036",
80 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
81 − "view": "56eb1796-fae8-4ffa-86a3-90a6c59c414f"
82 − },
83 − {
84 − "id": "si_ag_jfmorin",
85 − "name": "Équipe Jean-François Morin",
86 − "domain": "jeanfrancoismorin.ca",
87 − "account": "af1f2653-2806-40ad-84c0-cfccbdd5c4a8",
88 − "api": "3c4e9bea-a281-4226-8702-eb52bbae133e",
89 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
90 − "view": "27373493-9e40-4094-9664-12d2c8de2009"
91 − },
92 − {
93 − "id": "si_ag_jga",
94 − "name": "JGA Commercial (Équipe Jean-Guy Ayotte)",
95 − "domain": "equipejeanguyayotte.com",
96 − "account": "7d8a1086-f038-436d-ba6c-8fc1a81dc187",
97 − "api": "66d60651-2d83-4e82-91cc-b80a0c7e07b1",
98 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
99 − "view": "a1f2e4b6-ef44-4d97-a608-3cc9d7762f1f"
100 − },
101 − {
102 − "id": "si_ag_provad",
103 − "name": "Proulx Vadnais & Associés",
104 − "domain": "provad.ca",
105 − "account": "941e16f0-4cb6-413e-b9b1-257657b54717",
106 − "api": "d0c3b02c-45e2-4406-94de-aa4be5ad5c3d",
107 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
108 − "view": "2cf82736-4de7-44d2-bfa9-126d4874b872"
109 − },
110 − {
111 − "id": "si_ag_amyassaad",
112 − "name": "Équipe Amy Assaad",
113 − "domain": "amyassaad.ca",
114 − "account": "2058c3c0-ba8d-46e7-96f6-82d09c861fc3",
115 − "api": "738e7603-80de-4c7a-b954-e77662f012de",
116 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
117 − "view": "2facdd14-13a4-41d7-a5f0-ce7a9751ad26"
118 − },
119 − {
120 − "id": "si_ag_peladeau",
121 − "name": "Péladeau Agence Immobilière",
122 − "domain": "epeladeau.com",
123 − "account": "5602193c-a49c-4693-906f-8095cd5d2666",
124 − "api": "6f4339b4-a5eb-4826-a98b-a8d3b85bc92b",
125 − "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
126 − "view": "914b348a-48fe-42c5-96a2-52dd5f6c4307"
127 − },
128 47 {
129 48 "id": "si_ag_theagency",
130 49 "name": "The Agency Montréal",
modified data/sources.json +7 −176
@@ -65,17 +65,6 @@
65 65 "type": "agence",
66 66 "note": "Feed DDF (CREA) servi par API JSON interne POST propriodirect.com/fr/api/searchListings (corps {from:offset}, 30/page, champ total). ~3201 propriétés à vendre, données complètes + galeries photos. Hôte canonique sans www."
67 67 },
68 − {
69 − "id": "exp_ag_qc",
70 − "name": "eXp Agence immobilière (Québec)",
71 − "url": "https://expquebec.com",
72 − "listing_url": "https://expquebec.com/fr/properties/",
73 − "coverage": "Toute la province — agence virtuelle (~1000+ inscriptions)",
74 − "connector": "exp_ag_qc",
75 − "status": "actif",
76 − "type": "agence",
77 − "note": "Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Ville via map-property.php?mls={Centris}&lang=fr (enrich requests, plafonné+cache). source_id _ag_ = dédup Centris."
78 − },
79 68 {
80 69 "id": "kw_urbain",
81 70 "name": "Keller Williams Urbain",
@@ -351,173 +340,15 @@
351 340 "notes": "Plateforme QC — API anonyme api.ubee.ca SearchProperties (Seller, résidentiel+commercial), galerie complète."
352 341 },
353 342 {
354 − "id": "vendre_ag_ca",
355 − "name": "Vendre.ca",
356 − "url": "https://www.vendre.ca",
357 − "listing_url": "https://www.vendre.ca/sitemap.xml",
358 − "coverage": "Toute la province — portail hybride courtiers + vendeurs privés, ~9 500 fiches",
359 − "connector": "vendre_ca",
360 − "status": "actif",
361 − "type": "plateforme",
362 − "note": "Énumération via 4 sitemaps (res/mpx/com/lnd, URLs FR : type+ville+adresse+VID). Fiche détail JSON-LD Place+RealEstateListing+Offer (adresse exacte, géoloc, prix, année, courtier, n° MLS) + galerie cloud.vendre.ca _lrg ; enrichissement cache+plafond (IMMOKA_VENDRE_DETAIL_LIMIT). Infixe _ag_ : dédup Centris quand le MLS est connu (fiches de bannières couvertes masquées, vendeurs privés toujours visibles)."
363 − },
364 − {
365 − "id": "publimaison_ag_qc",
366 − "name": "Publimaison",
367 − "url": "https://www.publimaison.ca",
368 − "listing_url": "https://www.publimaison.ca/fr/recherche/",
369 − "coverage": "Toute la province — 2e FSBO du Québec (~5 400 annonces) + transferts Centris de courtiers",
370 − "connector": "publimaison",
371 − "status": "actif",
372 − "type": "fsbo",
373 − "note": "Recherche rendue serveur pilotée par pseudo-hash d'URL (8 cartes/page, ~680 pages, total « sur NNNN », pages promues qui se chevauchent -> arrêt après 3 pages sèches). Cartes riches (réf MLS, type, ville, prix, chambres, adresse, extrait, photo). Fiche détail HTML sans JSON-LD : galerie fr_maison_original, code postal, pièces+dimensions, taxes/évaluation (IMMOKA_PUBLIMAISON_DETAIL_LIMIT). Infixe _ag_ : réf 7-8 chiffres = n° MLS -> dédup Centris."
374 − },
375 − {
376 − "id": "logis_ag_quebec",
377 − "name": "LogisQuébec (à vendre)",
378 − "url": "https://www.logisquebec.com",
379 − "listing_url": "https://www.logisquebec.com/a-vendre",
380 − "coverage": "Toute la province — ~4 200 propriétés à vendre (particuliers + agences syndiquées)",
381 − "connector": "logisquebec",
382 − "status": "actif",
383 − "type": "plateforme",
384 − "note": "Section /a-vendre seulement (pas la location). Pagination /a-vendre/{page} (10 cartes/page, ~420 pages). Fiche détail JSON-LD SingleFamilyResidence+Product (adresse, code postal, géoloc, description, prix) + n° MLS dans le HTML + galerie i.logisquebec.com (IMMOKA_LOGISQC_DETAIL_LIMIT). Infixe _ag_ : dédup Centris pour les annonces d'agences (ex. Proprio Direct syndiqué), particuliers toujours visibles."
385 − },
386 − {
387 − "id": "si_ag_katia_samson",
388 − "name": "Groupe Immobilier Katia Samson",
389 − "url": "https://katiasamson.com",
390 − "coverage": "Grand Montréal / Ouest-de-l'Île — ~36 inscriptions",
391 − "connector": "agences_source_immo",
392 − "status": "actif",
393 − "type": "agence",
394 − "note": "Plateforme source.immo (config publique _configs.json). Infixe _ag_ : co-inscriptions Centris dédupliquées contre les bannières."
395 − },
396 − {
397 − "id": "si_ag_wparwilly",
398 − "name": "W par Willy Forge Agence Immobilière",
399 − "url": "https://wparwilly.com",
400 − "coverage": "Montréal — petite agence indépendante",
401 − "connector": "agences_source_immo",
402 − "status": "actif",
403 − "type": "agence",
404 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
405 − },
406 − {
407 − "id": "si_ag_avenueimmo",
408 − "name": "Avenue Immobilier",
409 − "url": "https://avenueimmo.com",
410 − "coverage": "~65 inscriptions",
411 − "connector": "agences_source_immo",
412 − "status": "actif",
413 − "type": "agence",
414 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
415 − },
416 − {
417 − "id": "si_ag_ferri",
418 − "name": "Services Immobiliers Ferri",
419 − "url": "https://ferri.ca",
420 − "coverage": "~48 inscriptions",
421 − "connector": "agences_source_immo",
422 − "status": "actif",
423 − "type": "agence",
424 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
425 − },
426 − {
427 − "id": "si_ag_jfmorin",
428 − "name": "Équipe Jean-François Morin",
429 − "url": "https://jeanfrancoismorin.ca",
430 − "coverage": "~44 inscriptions",
431 − "connector": "agences_source_immo",
432 − "status": "actif",
433 − "type": "agence",
434 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
435 − },
436 − {
437 − "id": "si_ag_jga",
438 − "name": "JGA Commercial (Équipe Jean-Guy Ayotte)",
439 − "url": "https://equipejeanguyayotte.com",
440 − "coverage": "Mauricie — ~92 inscriptions (Royal LePage, surtout commercial)",
441 − "connector": "agences_source_immo",
442 − "status": "actif",
443 − "type": "agence",
444 − "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)."
445 − },
446 − {
447 − "id": "si_ag_provad",
448 − "name": "Proulx Vadnais & Associés",
449 − "url": "https://provad.ca",
450 − "coverage": "~100+ inscriptions (commercial/multilogement)",
451 − "connector": "agences_source_immo",
452 − "status": "actif",
453 − "type": "agence",
454 − "note": "Plateforme source.immo (provad.ca = proulxvadnais.com, même compte). Généré par agences_source_immo.py."
455 − },
456 − {
457 − "id": "si_ag_amyassaad",
458 − "name": "Équipe Amy Assaad",
459 − "url": "https://amyassaad.ca",
460 − "coverage": "Montréal — ~100+ inscriptions (Royal LePage)",
461 − "connector": "agences_source_immo",
462 − "status": "actif",
463 − "type": "agence",
464 − "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)."
465 − },
466 − {
467 − "id": "si_ag_peladeau",
468 − "name": "Péladeau Agence Immobilière",
469 − "url": "https://epeladeau.com",
470 − "coverage": "~82 inscriptions",
471 − "connector": "agences_source_immo",
343 + "id": "exp_ag_qc",
344 + "name": "eXp Agence immobilière (Québec)",
345 + "url": "https://expquebec.com",
346 + "listing_url": "https://expquebec.com/fr/properties/",
347 + "coverage": "Toute la province — agence virtuelle (~1000+ inscriptions)",
348 + "connector": "exp_ag_qc",
472 349 "status": "actif",
473 350 "type": "agence",
474 − "note": "Plateforme source.immo. Généré par agences_source_immo.py."
475 − },
476 − {
477 − "id": "royal_lepage_humania",
478 − "name": "Royal LePage Humania",
479 − "url": "https://royallepagehumania.ca",
480 − "status": "ecarte",
481 − "note": "ÉCARTÉ : son compte source.immo (02f9532b-…) est IDENTIQUE à celui de Domicilia (domicilia.com = portail d'Humania, déjà couvert, 1 427 fiches). L'ajouter doublerait tout l'inventaire."
482 − },
483 − {
484 − "id": "adresz",
485 − "name": "Réseau Immobilier Adresz",
486 − "url": "https://www.adresz.ca",
487 − "listing_url": "https://www.adresz.ca/fr/inscriptions-propriete/",
488 − "coverage": "~55 courtiers, ~770 inscriptions",
489 − "status": "a_faire",
490 − "type": "agence",
491 − "note": "Site custom Symfony rendu serveur, pagination ?page=N&limit=12, photos /uploads/photos/. Pas de source.immo ni sitemap. Vérifier part actifs vs vendus avant d'écrire le connecteur."
492 − },
493 − {
494 − "id": "pmml",
495 − "name": "PMML (multilogement/commercial)",
496 − "url": "https://pmml.ca",
497 − "listing_url": "https://pmml.ca/proprietes",
498 − "coverage": "Leader QC multilogement/commercial — ~500 inscriptions",
499 − "status": "a_faire",
500 − "type": "agence",
501 − "note": "WordPress+Yoast mais tout derrière challenge Cloudflare (403) et fiches chargées en JS via API interne. Nécessite Scrapfly asp+render_js ; forte valeur segment multilogement."
502 − },
503 − {
504 − "id": "vistoo",
505 − "name": "Vistoo (immobilier neuf)",
506 − "url": "https://vistoo.com",
507 − "listing_url": "https://vistoo.com/sitemap.xml",
508 − "coverage": "Projets neufs QC — ~1 900 URLs for-sale au sitemap",
509 − "status": "a_faire",
510 − "type": "plateforme",
511 − "note": "Segment « neuf » (projets + unités) non couvert. React/SSR, sitemap riche, métadonnées propres. Complément : guidehabitation.ca (191 projets)."
512 − },
513 − {
514 − "id": "londono",
515 − "name": "Londono Realty Group",
516 − "url": "https://www.londonogroup.com",
517 − "coverage": "Westmount/Montréal — luxe, ~89 courtiers",
518 − "status": "a_faire",
519 − "type": "luxe",
520 − "note": "Site PHP custom daté (iso-8859-1, rendu serveur), pas de sitemap/robots ni source.immo ; scraper le moteur de recherche PHP directement. Volume ~200."
351 + "note": "Site officiel eXp QC (expquebec.com, WordPress + MW Properties). API cliente marketingwebsites.ca bloquée (whitelist IP) MAIS inventaire complet en SSR sur /fr/properties/?pages=N (9/page, param 'pages', stop après 3 pages vides). Carte: data-href mls={Centris}, data-price ($US), data-address, data-pic. Ville via map-property.php?mls={Centris}&lang=fr (enrich requests, plafonné+cache). source_id _ag_ = dédup Centris."
521 352 },
522 353 {
523 354 "id": "si_ag_theagency",
modified frontend/index.html +1 −1
@@ -8,7 +8,7 @@
8 8 <meta charset="UTF-8" />
9 9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 10 <title>Immo-Ka — Propriétés à vendre au Québec</title>
11 − <meta name="description" content="Des dizaines de milliers de propriétés à vendre partout au Québec, agrégées depuis les agences de courtage et plateformes (RE/MAX, Via Capitale, Sutton, Proprio Direct, DuProprio…) — mises à jour en continu, chaque fiche renvoie à l'annonce originale." />
11 + <meta name="description" content="Immo-Ka agrège les propriétés à vendre affichées par les agences de courtage immobilier du Québec (RE/MAX, Sutton, Via Capitale, Proprio Direct…), toujours à jour." />
12 12 <meta name="theme-color" content="#1a1214" />
13 13 <meta name="mobile-web-app-capable" content="yes" />
14 14 <meta name="apple-mobile-web-app-capable" content="yes" />
modified frontend/package-lock.json +371 −42
@@ -8,14 +8,12 @@
8 8 "name": "immo-ka-frontend",
9 9 "version": "1.0.0",
10 10 "dependencies": {
11 − "@groupe-ka/ka-maps": "file:../../ka-maps",
12 − "mapbox-gl": "^3.28.1",
11 + "maplibre-gl": "^4.7.1",
13 12 "react": "^18.3.1",
14 13 "react-dom": "^18.3.1",
15 14 "react-router-dom": "^6.26.0"
16 15 },
17 16 "devDependencies": {
18 − "@types/geojson": "^7946.0.16",
19 17 "@types/react": "^18.3.3",
20 18 "@types/react-dom": "^18.3.0",
21 19 "@vitejs/plugin-react": "^4.3.1",
@@ -23,29 +21,6 @@
23 21 "vite": "^5.4.0"
24 22 }
25 23 },
26 − "../../ka-maps": {
27 − "name": "@groupe-ka/ka-maps",
28 − "version": "0.1.0",
29 − "license": "UNLICENSED",
30 − "dependencies": {
31 − "@types/geojson": "^7946.0.16",
32 − "mapbox-gl": "^3.28.1"
33 − },
34 − "devDependencies": {
35 − "@types/react": "^19.0.0",
36 − "react": "^19.0.0",
37 − "typescript": "^5.8.0",
38 − "vitest": "^3.0.0"
39 − },
40 − "peerDependencies": {
41 − "react": ">=18"
42 − },
43 − "peerDependenciesMeta": {
44 − "react": {
45 − "optional": true
46 − }
47 − }
48 − },
49 24 "node_modules/@babel/code-frame": {
50 25 "version": "7.29.7",
51 26 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -719,10 +694,6 @@
719 694 "node": ">=12"
720 695 }
721 696 },
722 − "node_modules/@groupe-ka/ka-maps": {
723 − "resolved": "../../ka-maps",
724 − "link": true
725 − },
726 697 "node_modules/@jridgewell/gen-mapping": {
727 698 "version": "0.3.13",
728 699 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -773,6 +744,90 @@
773 744 "@jridgewell/sourcemap-codec": "^1.4.14"
774 745 }
775 746 },
747 + "node_modules/@mapbox/geojson-rewind": {
748 + "version": "0.5.2",
749 + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
750 + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
751 + "license": "ISC",
752 + "dependencies": {
753 + "get-stream": "^6.0.1",
754 + "minimist": "^1.2.6"
755 + },
756 + "bin": {
757 + "geojson-rewind": "geojson-rewind"
758 + }
759 + },
760 + "node_modules/@mapbox/jsonlint-lines-primitives": {
761 + "version": "2.0.3",
762 + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
763 + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
764 + "license": "MIT",
765 + "engines": {
766 + "node": ">= 22"
767 + }
768 + },
769 + "node_modules/@mapbox/point-geometry": {
770 + "version": "0.1.0",
771 + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
772 + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
773 + "license": "ISC"
774 + },
775 + "node_modules/@mapbox/tiny-sdf": {
776 + "version": "2.2.0",
777 + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
778 + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
779 + "license": "BSD-2-Clause"
780 + },
781 + "node_modules/@mapbox/unitbezier": {
782 + "version": "0.0.1",
783 + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
784 + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
785 + "license": "BSD-2-Clause"
786 + },
787 + "node_modules/@mapbox/vector-tile": {
788 + "version": "1.3.1",
789 + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
790 + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
791 + "license": "BSD-3-Clause",
792 + "dependencies": {
793 + "@mapbox/point-geometry": "~0.1.0"
794 + }
795 + },
796 + "node_modules/@mapbox/whoots-js": {
797 + "version": "3.1.0",
798 + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
799 + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
800 + "license": "ISC",
801 + "engines": {
802 + "node": ">=6.0.0"
803 + }
804 + },
805 + "node_modules/@maplibre/maplibre-gl-style-spec": {
806 + "version": "20.4.0",
807 + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
808 + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
809 + "license": "ISC",
810 + "dependencies": {
811 + "@mapbox/jsonlint-lines-primitives": "~2.0.2",
812 + "@mapbox/unitbezier": "^0.0.1",
813 + "json-stringify-pretty-compact": "^4.0.0",
814 + "minimist": "^1.2.8",
815 + "quickselect": "^2.0.0",
816 + "rw": "^1.3.3",
817 + "tinyqueue": "^3.0.0"
818 + },
819 + "bin": {
820 + "gl-style-format": "dist/gl-style-format.mjs",
821 + "gl-style-migrate": "dist/gl-style-migrate.mjs",
822 + "gl-style-validate": "dist/gl-style-validate.mjs"
823 + }
824 + },
825 + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
826 + "version": "2.0.0",
827 + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
828 + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
829 + "license": "ISC"
830 + },
776 831 "node_modules/@napi-rs/lzma-linux-x64-gnu": {
777 832 "version": "1.5.1",
778 833 "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1254,7 +1309,38 @@
1254 1309 "version": "7946.0.16",
1255 1310 "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1256 1311 "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1257 − "dev": true,
1312 + "license": "MIT"
1313 + },
1314 + "node_modules/@types/geojson-vt": {
1315 + "version": "3.2.5",
1316 + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
1317 + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
1318 + "license": "MIT",
1319 + "dependencies": {
1320 + "@types/geojson": "*"
1321 + }
1322 + },
1323 + "node_modules/@types/mapbox__point-geometry": {
1324 + "version": "0.1.4",
1325 + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
1326 + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
1327 + "license": "MIT"
1328 + },
1329 + "node_modules/@types/mapbox__vector-tile": {
1330 + "version": "1.3.4",
1331 + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
1332 + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
1333 + "license": "MIT",
1334 + "dependencies": {
1335 + "@types/geojson": "*",
1336 + "@types/mapbox__point-geometry": "*",
1337 + "@types/pbf": "*"
1338 + }
1339 + },
1340 + "node_modules/@types/pbf": {
1341 + "version": "3.0.5",
1342 + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
1343 + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
1258 1344 "license": "MIT"
1259 1345 },
1260 1346 "node_modules/@types/prop-types": {
@@ -1285,6 +1371,15 @@
1285 1371 "@types/react": "^18.0.0"
1286 1372 }
1287 1373 },
1374 + "node_modules/@types/supercluster": {
1375 + "version": "7.1.3",
1376 + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
1377 + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
1378 + "license": "MIT",
1379 + "dependencies": {
1380 + "@types/geojson": "*"
1381 + }
1382 + },
1288 1383 "node_modules/@vitejs/plugin-react": {
1289 1384 "version": "4.7.0",
1290 1385 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1406,6 +1501,12 @@
1406 1501 }
1407 1502 }
1408 1503 },
1504 + "node_modules/earcut": {
1505 + "version": "3.2.3",
1506 + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
1507 + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
1508 + "license": "ISC"
1509 + },
1409 1510 "node_modules/electron-to-chromium": {
1410 1511 "version": "1.5.405",
1411 1512 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
@@ -1487,6 +1588,82 @@
1487 1588 "node": ">=6.9.0"
1488 1589 }
1489 1590 },
1591 + "node_modules/geojson-vt": {
1592 + "version": "4.0.3",
1593 + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz",
1594 + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==",
1595 + "license": "ISC"
1596 + },
1597 + "node_modules/get-stream": {
1598 + "version": "6.0.1",
1599 + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
1600 + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
1601 + "license": "MIT",
1602 + "engines": {
1603 + "node": ">=10"
1604 + },
1605 + "funding": {
1606 + "url": "https://github.com/sponsors/sindresorhus"
1607 + }
1608 + },
1609 + "node_modules/gl-matrix": {
1610 + "version": "3.4.4",
1611 + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
1612 + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
1613 + "license": "MIT"
1614 + },
1615 + "node_modules/global-prefix": {
1616 + "version": "4.0.0",
1617 + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
1618 + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
1619 + "license": "MIT",
1620 + "dependencies": {
1621 + "ini": "^4.1.3",
1622 + "kind-of": "^6.0.3",
1623 + "which": "^4.0.0"
1624 + },
1625 + "engines": {
1626 + "node": ">=16"
1627 + }
1628 + },
1629 + "node_modules/ieee754": {
1630 + "version": "1.2.1",
1631 + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
1632 + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
1633 + "funding": [
1634 + {
1635 + "type": "github",
1636 + "url": "https://github.com/sponsors/feross"
1637 + },
1638 + {
1639 + "type": "patreon",
1640 + "url": "https://www.patreon.com/feross"
1641 + },
1642 + {
1643 + "type": "consulting",
1644 + "url": "https://feross.org/support"
1645 + }
1646 + ],
1647 + "license": "BSD-3-Clause"
1648 + },
1649 + "node_modules/ini": {
1650 + "version": "4.1.3",
1651 + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
1652 + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
1653 + "license": "ISC",
1654 + "engines": {
1655 + "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
1656 + }
1657 + },
1658 + "node_modules/isexe": {
1659 + "version": "3.1.5",
1660 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
1661 + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
1662 + "license": "BlueOak-1.0.0",
1663 + "engines": {
1664 + "node": ">=18"
1665 + }
1666 + },
1490 1667 "node_modules/js-tokens": {
1491 1668 "version": "4.0.0",
1492 1669 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1506,6 +1683,12 @@
1506 1683 "node": ">=6"
1507 1684 }
1508 1685 },
1686 + "node_modules/json-stringify-pretty-compact": {
1687 + "version": "4.0.0",
1688 + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
1689 + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
1690 + "license": "MIT"
1691 + },
1509 1692 "node_modules/json5": {
1510 1693 "version": "2.2.3",
1511 1694 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1519,6 +1702,21 @@
1519 1702 "node": ">=6"
1520 1703 }
1521 1704 },
1705 + "node_modules/kdbush": {
1706 + "version": "4.1.0",
1707 + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
1708 + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
1709 + "license": "ISC"
1710 + },
1711 + "node_modules/kind-of": {
1712 + "version": "6.0.3",
1713 + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
1714 + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
1715 + "license": "MIT",
1716 + "engines": {
1717 + "node": ">=0.10.0"
1718 + }
1719 + },
1522 1720 "node_modules/loose-envify": {
1523 1721 "version": "1.4.0",
1524 1722 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1541,17 +1739,55 @@
1541 1739 "yallist": "^3.0.2"
1542 1740 }
1543 1741 },
1544 − "node_modules/mapbox-gl": {
1545 − "version": "3.28.1",
1546 − "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1547 − "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1548 − "license": "SEE LICENSE IN LICENSE.txt",
1549 − "workspaces": [
1550 − "src/style-spec",
1551 − "plugins/mapbox-gl-pmtiles-provider",
1552 − "test/bundlers/*",
1553 − "test/build/typings"
1554 − ]
1742 + "node_modules/maplibre-gl": {
1743 + "version": "4.7.1",
1744 + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
1745 + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
1746 + "license": "BSD-3-Clause",
1747 + "dependencies": {
1748 + "@mapbox/geojson-rewind": "^0.5.2",
1749 + "@mapbox/jsonlint-lines-primitives": "^2.0.2",
1750 + "@mapbox/point-geometry": "^0.1.0",
1751 + "@mapbox/tiny-sdf": "^2.0.6",
1752 + "@mapbox/unitbezier": "^0.0.1",
1753 + "@mapbox/vector-tile": "^1.3.1",
1754 + "@mapbox/whoots-js": "^3.1.0",
1755 + "@maplibre/maplibre-gl-style-spec": "^20.3.1",
1756 + "@types/geojson": "^7946.0.14",
1757 + "@types/geojson-vt": "3.2.5",
1758 + "@types/mapbox__point-geometry": "^0.1.4",
1759 + "@types/mapbox__vector-tile": "^1.3.4",
1760 + "@types/pbf": "^3.0.5",
1761 + "@types/supercluster": "^7.1.3",
1762 + "earcut": "^3.0.0",
1763 + "geojson-vt": "^4.0.2",
1764 + "gl-matrix": "^3.4.3",
1765 + "global-prefix": "^4.0.0",
1766 + "kdbush": "^4.0.2",
1767 + "murmurhash-js": "^1.0.0",
1768 + "pbf": "^3.3.0",
1769 + "potpack": "^2.0.0",
1770 + "quickselect": "^3.0.0",
1771 + "supercluster": "^8.0.1",
1772 + "tinyqueue": "^3.0.0",
1773 + "vt-pbf": "^3.1.3"
1774 + },
1775 + "engines": {
1776 + "node": ">=16.14.0",
1777 + "npm": ">=8.1.0"
1778 + },
1779 + "funding": {
1780 + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
1781 + }
1782 + },
1783 + "node_modules/minimist": {
1784 + "version": "1.2.8",
1785 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1786 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1787 + "license": "MIT",
1788 + "funding": {
1789 + "url": "https://github.com/sponsors/ljharb"
1790 + }
1555 1791 },
1556 1792 "node_modules/ms": {
1557 1793 "version": "2.1.3",
@@ -1560,6 +1796,12 @@
1560 1796 "dev": true,
1561 1797 "license": "MIT"
1562 1798 },
1799 + "node_modules/murmurhash-js": {
1800 + "version": "1.0.0",
1801 + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
1802 + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
1803 + "license": "MIT"
1804 + },
1563 1805 "node_modules/nanoid": {
1564 1806 "version": "3.3.18",
1565 1807 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -1589,6 +1831,19 @@
1589 1831 "node": ">=18"
1590 1832 }
1591 1833 },
1834 + "node_modules/pbf": {
1835 + "version": "3.3.0",
1836 + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
1837 + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
1838 + "license": "BSD-3-Clause",
1839 + "dependencies": {
1840 + "ieee754": "^1.1.12",
1841 + "resolve-protobuf-schema": "^2.1.0"
1842 + },
1843 + "bin": {
1844 + "pbf": "bin/pbf"
1845 + }
1846 + },
1592 1847 "node_modules/picocolors": {
1593 1848 "version": "1.1.1",
1594 1849 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1625,6 +1880,24 @@
1625 1880 "node": "^10 || ^12 || >=14"
1626 1881 }
1627 1882 },
1883 + "node_modules/potpack": {
1884 + "version": "2.1.0",
1885 + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
1886 + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
1887 + "license": "ISC"
1888 + },
1889 + "node_modules/protocol-buffers-schema": {
1890 + "version": "3.6.1",
1891 + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
1892 + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
1893 + "license": "MIT"
1894 + },
1895 + "node_modules/quickselect": {
1896 + "version": "3.0.0",
1897 + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
1898 + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
1899 + "license": "ISC"
1900 + },
1628 1901 "node_modules/react": {
1629 1902 "version": "18.3.1",
1630 1903 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1692,6 +1965,15 @@
1692 1965 "react-dom": ">=16.8"
1693 1966 }
1694 1967 },
1968 + "node_modules/resolve-protobuf-schema": {
1969 + "version": "2.1.0",
1970 + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
1971 + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
1972 + "license": "MIT",
1973 + "dependencies": {
1974 + "protocol-buffers-schema": "^3.3.1"
1975 + }
1976 + },
1695 1977 "node_modules/rollup": {
1696 1978 "version": "4.62.4",
1697 1979 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1738,6 +2020,12 @@
1738 2020 "fsevents": "~2.3.2"
1739 2021 }
1740 2022 },
2023 + "node_modules/rw": {
2024 + "version": "1.3.3",
2025 + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
2026 + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
2027 + "license": "BSD-3-Clause"
2028 + },
1741 2029 "node_modules/scheduler": {
1742 2030 "version": "0.23.2",
1743 2031 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1767,6 +2055,21 @@
1767 2055 "node": ">=0.10.0"
1768 2056 }
1769 2057 },
2058 + "node_modules/supercluster": {
2059 + "version": "8.0.1",
2060 + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
2061 + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
2062 + "license": "ISC",
2063 + "dependencies": {
2064 + "kdbush": "^4.0.2"
2065 + }
2066 + },
2067 + "node_modules/tinyqueue": {
2068 + "version": "3.0.0",
2069 + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
2070 + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
2071 + "license": "ISC"
2072 + },
1770 2073 "node_modules/typescript": {
1771 2074 "version": "5.9.3",
1772 2075 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1872,6 +2175,32 @@
1872 2175 }
1873 2176 }
1874 2177 },
2178 + "node_modules/vt-pbf": {
2179 + "version": "3.1.3",
2180 + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
2181 + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
2182 + "license": "MIT",
2183 + "dependencies": {
2184 + "@mapbox/point-geometry": "0.1.0",
2185 + "@mapbox/vector-tile": "^1.3.1",
2186 + "pbf": "^3.2.1"
2187 + }
2188 + },
2189 + "node_modules/which": {
2190 + "version": "4.0.0",
2191 + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
2192 + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
2193 + "license": "ISC",
2194 + "dependencies": {
2195 + "isexe": "^3.1.1"
2196 + },
2197 + "bin": {
2198 + "node-which": "bin/which.js"
2199 + },
2200 + "engines": {
2201 + "node": "^16.13.0 || >=18.0.0"
2202 + }
2203 + },
1875 2204 "node_modules/yallist": {
1876 2205 "version": "3.1.1",
1877 2206 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
modified frontend/package.json +1 −3
@@ -10,14 +10,12 @@
10 10 "preview": "vite preview"
11 11 },
12 12 "dependencies": {
13 − "@groupe-ka/ka-maps": "file:../../ka-maps",
14 − "mapbox-gl": "^3.28.1",
13 + "maplibre-gl": "^4.7.1",
15 14 "react": "^18.3.1",
16 15 "react-dom": "^18.3.1",
17 16 "react-router-dom": "^6.26.0"
18 17 },
19 18 "devDependencies": {
20 − "@types/geojson": "^7946.0.16",
21 19 "@types/react": "^18.3.3",
22 20 "@types/react-dom": "^18.3.0",
23 21 "@vitejs/plugin-react": "^4.3.1",
modified frontend/src/App.tsx +6 −77
@@ -3,16 +3,12 @@
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 5 // -----------------------------------------------------------------------------
6 −import { Ico, IcoHeart } from "./components/Icons";
6 +import { Ico } from "./components/Icons";
7 7 import { useEffect, useState } from "react";
8 −import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";
9 −import { fetchFacets, fetchSources, fetchStats, logout, registerSourceNames, sourceName } from "./api";
10 −import { AccountProvider, useAccount } from "./account";
11 −import CategoryPage from "./pages/Category";
8 +import { NavLink, Route, Routes, useLocation } from "react-router-dom";
9 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
12 10 import Home from "./pages/Home";
13 −import { PrivacyPage, TermsPage } from "./pages/Legal";
14 11 import ListingPage from "./pages/Listing";
15 −import ProfilPage from "./pages/Profil";
16 12 import SourcesPage from "./pages/Sources";
17 13 import StatsPage from "./pages/Stats";
18 14
@@ -47,60 +43,11 @@ function Ticker() {
47 43 );
48 44 }
49 45
50 −function AuthWidget() {
51 − const { user, enabled, refresh } = useAccount();
52 − const [open, setOpen] = useState(false);
53 −
54 − if (!user && !enabled) return null; // connexion non configurée côté serveur
55 − if (!user)
56 − return (
57 − <a className="auth-btn" href={`/api/auth/ka/login?next=${encodeURIComponent(location.pathname)}`}>
58 − <span className="auth-kab" aria-hidden="true">KA</span>
59 − KA ID
60 − </a>
61 − );
62 − return (
63 − <div className="auth-user">
64 − <button className="auth-avatar" onClick={() => setOpen(!open)}
65 − aria-label={`Compte de ${user.name}`} aria-expanded={open}>
66 − {user.picture
67 − ? <img src={user.picture} alt="" referrerPolicy="no-referrer" />
68 − : <span>{(user.name || user.email || "?").slice(0, 1).toUpperCase()}</span>}
69 − </button>
70 − {open && (
71 − <div className="auth-menu" role="menu">
72 − <div className="auth-id">
73 − <b>{user.name}</b>
74 − <span>{user.email}</span>
75 − </div>
76 − {user.ka_id && (
77 − <div className="auth-note" style={{ fontFamily: "monospace", letterSpacing: "0.08em" }}>
78 − KA-ID : {user.ka_id}
79 − </div>
80 − )}
81 − <div className="auth-note">Compte Groupe KA — le même KA-ID sur toutes les plateformes du groupe</div>
82 − <Link className="auth-menu-link" to="/profil" onClick={() => setOpen(false)}>
83 − Mon profil
84 − </Link>
85 − <a className="auth-menu-link" href="https://www.groupe-ka.com/compte"
86 − target="_blank" rel="noopener noreferrer" onClick={() => setOpen(false)}>
87 − <IcoHeart size={13} filled /> Mes favoris — Mon univers Ka ↗
88 − </a>
89 − <button onClick={() => logout().then(() => { refresh(); setOpen(false); })}>
90 − Se déconnecter
91 − </button>
92 − </div>
93 − )}
94 − </div>
95 − );
96 −}
97 −
98 46 const NAV_LINKS = [
99 47 { to: "/", label: "Propriétés", icon: "home", end: true },
100 48 { to: "/?view=carte", label: "Carte", icon: "map", end: false, force: true },
101 49 { to: "/stats", label: "Stats", icon: "chart", end: false },
102 50 { to: "/agences", label: "Agences", icon: "building", end: false },
103 − { to: "/profil", label: "Mon profil", icon: "people", end: false },
104 51 ];
105 52
106 53 function Header() {
@@ -132,9 +79,6 @@ function Header() {
132 79 Agences
133 80 </NavLink>
134 81 </nav>
135 − <div className="auth-slot">
136 − <AuthWidget />
137 − </div>
138 82 <button
139 83 className={`menu-btn ${open ? "open" : ""}`}
140 84 aria-expanded={open}
@@ -187,15 +131,8 @@ function Footer() {
187 131 automatiquement — chaque fiche renvoie vers l'annonce originale.
188 132 </div>
189 133 </div>
190 − <div className="flegal">
191 − <NavLink to="/conditions">Conditions d'utilisation</NavLink>
192 − <span aria-hidden="true"> · </span>
193 − <NavLink to="/confidentialite">Politique de confidentialité</NavLink>
194 − </div>
195 134 <div className="fmono">
196 − Immo-Ka est une application du <b>Groupe-Ka</b> ·
197 − © {new Date().getFullYear()} Groupe-Ka —{" "}
198 − <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>
135 + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai
199 136 </div>
200 137 </div>
201 138 </footer>
@@ -204,22 +141,14 @@ function Footer() {
204 141
205 142 export default function App() {
206 143 return (
207 − <AccountProvider>
144 + <>
208 145 <Header />
209 146 <main>
210 147 <Routes>
211 148 <Route path="/" element={<Home />} />
212 149 <Route path="/propriete/:uid" element={<ListingPage />} />
213 − <Route path="/propriete/:uid/:slug" element={<ListingPage />} />
214 − {/* pages programmatiques SEO — mêmes URL que le rendu serveur */}
215 − <Route path="/a-vendre/:ville" element={<CategoryPage />} />
216 − <Route path="/a-vendre/:ville/:type" element={<CategoryPage />} />
217 − <Route path="/type/:type" element={<CategoryPage />} />
218 150 <Route path="/stats" element={<StatsPage />} />
219 151 <Route path="/agences" element={<SourcesPage />} />
220 − <Route path="/profil" element={<ProfilPage />} />
221 − <Route path="/conditions" element={<TermsPage />} />
222 − <Route path="/confidentialite" element={<PrivacyPage />} />
223 152 <Route
224 153 path="*"
225 154 element={
@@ -233,6 +162,6 @@ export default function App() {
233 162 </Routes>
234 163 </main>
235 164 <Footer />
236 − </AccountProvider>
165 + </>
237 166 );
238 167 }
modified frontend/src/api.ts +0 −112
@@ -192,40 +192,6 @@ export const fmtDate = (ts: number): string =>
192 192 day: "numeric", month: "long", year: "numeric",
193 193 });
194 194
195 −// --- Référencement ------------------------------------------------------------
196 −/** Slug URL — MÊME algorithme que slugify() de immoka/seo.py. */
197 −export function slugify(s: string): string {
198 − return (s || "")
199 − .normalize("NFKD")
200 − .replace(/[\u0300-\u036f]/g, "") // diacritiques combinants (é → e)
201 − .replace(/[^\x00-\x7f]/g, "") // équivalent de encode("ascii","ignore")
202 − .toLowerCase()
203 − .replace(/[^a-z0-9]+/g, "-")
204 − .replace(/^-+|-+$/g, "")
205 − .slice(0, 80)
206 − .replace(/-+$/g, "");
207 −}
208 −
209 −/** Chemin canonique d'une fiche : /propriete/{uid}/{slug-adresse-ville}. */
210 −export function fichePath(l: Pick<Listing, "uid" | "address" | "title" | "city">): string {
211 − let slug = slugify(l.address || l.title || "");
212 − const city = slugify(l.city || "");
213 − if (city && !slug.includes(city)) slug = slug ? slugify(`${slug} ${city}`) : city;
214 − return `/propriete/${encodeURIComponent(l.uid)}${slug ? `/${slug}` : ""}`;
215 −}
216 −
217 −/** Titre d'onglet par page (le HTML initial est déjà titré côté serveur). */
218 −export function setDocTitle(t?: string) {
219 − document.title = t ? `${t} | Immo-Ka` : "Immo-Ka — Propriétés à vendre au Québec";
220 −}
221 −
222 −/** Slug de page programmatique → valeurs exactes (ville, type). */
223 −export const resolveSeo = (ville?: string, type?: string) =>
224 − get<{ city?: string; city_n?: number; property_type?: string; type_n?: number }>(
225 − `/api/seo/resolve?${new URLSearchParams({
226 − ...(ville ? { ville } : {}), ...(type ? { type } : {}),
227 − })}`);
228 −
229 195 // --- Vrai-Prix : estimation de valeur marchande ------------------------------
230 196 export interface VraiPrix {
231 197 id: string;
@@ -253,81 +219,3 @@ export interface Quartier {
253 219 | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number }
254 220 | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null };
255 221 }
256 −
257 −// --- Compte Groupe-Ka (KA ID — hub d'identité groupe-ka.com) -----------------
258 −/** Réseaux sociaux du profil Groupe KA (saisis au hub, affichés partout). */
259 −export interface Socials {
260 − instagram?: string;
261 − facebook?: string;
262 − x?: string;
263 − linkedin?: string;
264 − tiktok?: string;
265 − youtube?: string;
266 −}
267 −
268 −export interface User {
269 − sub: string;
270 − email: string;
271 − name: string;
272 − picture: string;
273 − ka_id?: string;
274 − provider?: string; // "ka-id" | "google"
275 − created_at?: number | null; // « Membre depuis » (hub si relié, sinon local)
276 − last_login?: number | null;
277 − // — profil Groupe KA (source de vérité : groupe-ka.com/compte) —
278 − profile_source?: "groupe-ka" | "local";
279 − bio?: string;
280 − city?: string;
281 − phone?: string;
282 − website?: string;
283 − socials?: Socials;
284 − job_title?: string;
285 − company?: string;
286 − age?: number | null;
287 − role_label?: string;
288 − public?: boolean;
289 − public_url?: string;
290 −}
291 −export const fetchMe = () => get<{ user: User | null; enabled?: boolean }>("/api/auth/me");
292 −export async function logout(): Promise<void> {
293 − await fetch("/api/auth/logout", { method: "POST" });
294 −}
295 −
296 −// --- Favoris ♥ (magasin central au hub Groupe KA — « Mon univers Ka ») -------
297 −// Immo-Ka ne stocke RIEN localement : GET relit le hub (cache serveur 30 s),
298 −// POST /toggle pousse add/remove au hub. 401 -> connexion KA ID requise.
299 −export interface FavItem {
300 − item_id: string;
301 − title: string;
302 − subtitle?: string;
303 − price_label?: string;
304 − image_url?: string;
305 − url?: string;
306 − app?: string;
307 −}
308 −
309 −export const fetchFavorites = () =>
310 − get<{ ids: string[]; items: FavItem[] }>("/api/favorites");
311 −
312 −/** Pousse le toggle au hub ; lève Error("401") si la session KA manque. */
313 −export async function toggleFavorite(on: boolean, item: FavItem): Promise<boolean> {
314 − const res = await fetch("/api/favorites/toggle", {
315 − method: "POST",
316 − headers: { "Content-Type": "application/json" },
317 − body: JSON.stringify({ on, item }),
318 − });
319 − if (res.status === 401) throw new Error("401");
320 − return res.ok;
321 −}
322 −
323 −/** Compose l'item de favori du hub à partir d'une propriété. */
324 −export function favItemFromListing(l: Listing): FavItem {
325 − return {
326 − item_id: l.uid,
327 − title: l.address || l.title || "Propriété",
328 − subtitle: [l.city, l.property_type].filter(Boolean).join(" · "),
329 − price_label: fmtPrice(l.price, l.price_label),
330 − image_url: (l.images && l.images[0]) || "",
331 − url: `https://www.immo-ka.com/propriete/${encodeURIComponent(l.uid)}`,
332 − };
333 −}
modified frontend/src/components/Icons.tsx +0 −42
@@ -154,33 +154,6 @@ const P: Record<string, ReactNode> = {
154 154 <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
155 155 <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
156 156 </>),
157 −
158 − // --- réseaux sociaux (profil Groupe KA) -------------------------------------
159 − instagram: (<>
160 − <rect x="4" y="4" width="16" height="16" rx="4.6" />
161 − <circle cx="12" cy="12" r="3.6" />
162 − <path d="M16.6 7.4v.05" />
163 − </>),
164 − facebook: (<>
165 − <path d="M14.8 4.2h-2.2a3.4 3.4 0 0 0-3.4 3.4V10H6.8v3.4h2.4V20h3.4v-6.6h2.6l.6-3.4h-3.2V8a1 1 0 0 1 1-1h2.2z" />
166 − </>),
167 − x: (<>
168 − <path d="M4.6 4.4h4.2L19.4 19.6h-4.2z" />
169 − <path d="M19 4.4 13.4 11M10.4 13.2 4.8 19.6" />
170 − </>),
171 − linkedin: (<>
172 − <rect x="4" y="4" width="16" height="16" rx="2.4" />
173 − <path d="M8 10.4V16.4M8 7.6v.05" />
174 − <path d="M11.6 16.4v-6M11.6 12.6a2.4 2.4 0 0 1 4.8 0v3.8" />
175 − </>),
176 − tiktok: (<>
177 − <path d="M13.6 3.8v10.9a3.5 3.5 0 1 1-3.5-3.5" />
178 − <path d="M13.6 5.4a5.4 5.4 0 0 0 5.2 4" />
179 − </>),
180 − youtube: (<>
181 − <rect x="3.2" y="6" width="17.6" height="12.4" rx="3.4" />
182 − <path d="m10.4 9.4 4.6 2.8-4.6 2.8z" />
183 − </>),
184 157 };
185 158
186 159 export type IconName = keyof typeof P;
@@ -201,21 +174,6 @@ export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
201 174 );
202 175 }
203 176
204 −/** Cœur des favoris ♥ — seul icône « remplissable » (état on/off). */
205 −export function IcoHeart({ size = 16, filled = false, className = "" }:
206 − { size?: number; filled?: boolean; className?: string }) {
207 − return (
208 − <svg
209 − className={`ico ${className}`}
210 − width={size} height={size} viewBox="0 0 24 24"
211 − fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7}
212 − strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
213 − >
214 − <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
215 − </svg>
216 − );
217 −}
218 −
219 177 /** Version « chaîne HTML » pour les popups MapLibre (hors React). */
220 178 export function icoHTML(name: IconName, size = 30): string {
221 179 const d: Record<string, string> = {
modified frontend/src/components/ListingCard.tsx +3 −14
@@ -4,14 +4,11 @@
4 4 // components/ListingCard.tsx : carte de propriété (grille de résultats)
5 5 // -----------------------------------------------------------------------------
6 6 import { Link } from "react-router-dom";
7 −import { Listing, fichePath, fmtArea, fmtPrice, sourceName } from "../api";
8 −import { useAccount } from "../account";
9 −import { Ico, IcoHeart } from "./Icons";
7 +import { Listing, fmtArea, fmtPrice, sourceName } from "../api";
8 +import { Ico } from "./Icons";
10 9
11 10 export default function ListingCard({ l }: { l: Listing }) {
12 11 const img = l.images && l.images.length > 0 ? l.images[0] : null;
13 − const { favs, toggleFav } = useAccount();
14 − const fav = favs.has(l.uid);
15 12 const meta: { ico: string; txt: string }[] = [];
16 13 if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} ch.` });
17 14 if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${l.bathrooms} sdb` });
@@ -19,7 +16,7 @@ export default function ListingCard({ l }: { l: Listing }) {
19 16 if (area) meta.push({ ico: "area", txt: area });
20 17
21 18 return (
22 − <Link to={fichePath(l)} className="card">
19 + <Link to={`/propriete/${encodeURIComponent(l.uid)}`} className="card">
23 20 <div className="card-img">
24 21 {img ? (
25 22 <img src={img} alt={l.title || l.address} loading="lazy" />
@@ -30,14 +27,6 @@ export default function ListingCard({ l }: { l: Listing }) {
30 27 {l.images.length > 1 && (
31 28 <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span>
32 29 )}
33 − <button
34 − className={`fav-btn ${fav ? "on" : ""}`}
35 − aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"}
36 − aria-pressed={fav}
37 − onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(l); }}
38 − >
39 − <IcoHeart size={16} filled={fav} />
40 − </button>
41 30 </div>
42 31 <div className="card-body">
43 32 <div className="card-price">{fmtPrice(l.price, l.price_label)}</div>
modified frontend/src/components/MapView.tsx +0 −0

Binary file not shown.

modified frontend/src/components/PropertyMap.tsx +46 −54
@@ -1,100 +1,92 @@
1 1 // -----------------------------------------------------------------------------
2 −// Author: Simon-Pierre Boucher
3 −// Contact: contact@spboucher.ai
4 −// Project: Groupe Ka / Ka Maps (Immo-Ka integration)
5 −// components/PropertyMap.tsx : mini-carte d'une propriété (fiche détail).
6 −// Fond Ka Maps (Mapbox Standard 3D, réglages immobiliers), anneaux de
7 −// marchabilité 5/15 min et marqueur-prix pulsant.
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/PropertyMap.tsx : carte d'emplacement de la fiche propriété
5 +// · centrée et zoomée sur LA propriété (fond « Groupe Ka » maison)
6 +// · marqueur pulsant + pastille de prix, rouge si sous l'estimation Vrai-Prix
7 +// · anneaux « 5 min / 15 min à pied » — signature visuelle Immo-Ka
8 8 // -----------------------------------------------------------------------------
9 9 import { useEffect, useRef } from "react";
10 −import mapboxgl from "mapbox-gl";
11 −import "mapbox-gl/dist/mapbox-gl.css";
12 −import { applyKaBasemapConfig, KA_STYLE_URL } from "@groupe-ka/ka-maps";
13 −import { formatCompactPrice } from "@groupe-ka/ka-maps";
14 −import { MAPBOX_TOKEN } from "../kamaps/config";
10 +import maplibregl from "maplibre-gl";
11 +import "maplibre-gl/dist/maplibre-gl.css";
12 +import immokaStyle from "../map/immokaStyle";
13 +import { shortPrice } from "../map/pills";
15 14
16 −/** Cercle géodésique approché (72 côtés) pour les anneaux de marche. */
15 +/** Cercle géodésique approx. (polygone 72 côtés) autour d'un point. */
17 16 function circle(lng: number, lat: number, radiusM: number): GeoJSON.Feature {
18 17 const pts: [number, number][] = [];
18 + const dLat = radiusM / 111_000;
19 + const dLng = radiusM / (111_000 * Math.cos((lat * Math.PI) / 180));
19 20 for (let i = 0; i <= 72; i++) {
20 21 const a = (i / 72) * 2 * Math.PI;
21 − const dLat = (radiusM * Math.cos(a)) / 111_000;
22 − const dLng = (radiusM * Math.sin(a)) / (111_000 * Math.cos((lat * Math.PI) / 180));
23 − pts.push([lng + dLng, lat + dLat]);
22 + pts.push([lng + dLng * Math.cos(a), lat + dLat * Math.sin(a)]);
24 23 }
25 − return { type: "Feature", geometry: { type: "Polygon", coordinates: [pts] }, properties: {} };
24 + return { type: "Feature", properties: {},
25 + geometry: { type: "Polygon", coordinates: [pts] } };
26 26 }
27 27
28 −export default function PropertyMap({ lat, lng, price, deal }: {
28 +interface Props {
29 29 lat: number;
30 30 lng: number;
31 31 price?: number | null;
32 − deal?: boolean;
33 −}) {
32 + deal?: boolean; // prix sous l'estimation Vrai-Prix
33 +}
34 +
35 +export default function PropertyMap({ lat, lng, price, deal }: Props) {
34 36 const div = useRef<HTMLDivElement>(null);
35 37
36 38 useEffect(() => {
37 39 if (!div.current) return;
38 − const map = new mapboxgl.Map({
40 + const map = new maplibregl.Map({
39 41 container: div.current,
40 − accessToken: MAPBOX_TOKEN,
41 − style: KA_STYLE_URL,
42 + style: immokaStyle,
42 43 center: [lng, lat],
43 − zoom: 15.2,
44 − pitch: 45,
45 − scrollZoom: false,
46 − dragRotate: false,
44 + zoom: 14.6,
47 45 attributionControl: false,
46 + scrollZoom: false, // ne pas piéger le défilement de la page
47 + dragRotate: false,
48 48 });
49 − map.addControl(new mapboxgl.AttributionControl({ compact: true }), "bottom-right");
50 − map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), "top-right");
49 + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
51 50 map.touchZoomRotate.disableRotation();
52 51
53 − map.once("load", () => {
54 − applyKaBasemapConfig(map, "light");
52 + map.on("load", () => {
53 + // anneaux piéton — 400 m ≈ 5 min, 1,2 km ≈ 15 min
55 54 map.addSource("rings", {
56 55 type: "geojson",
57 − data: {
58 − type: "FeatureCollection",
59 − features: [circle(lng, lat, 400), circle(lng, lat, 1200)],
60 − },
56 + data: { type: "FeatureCollection",
57 + features: [circle(lng, lat, 400), circle(lng, lat, 1200)] },
61 58 });
62 59 map.addLayer({
63 − id: "rings-fill", type: "fill", source: "rings", slot: "top",
60 + id: "rings-fill", type: "fill", source: "rings",
64 61 paint: { "fill-color": "#e23744", "fill-opacity": 0.035 },
65 62 });
66 63 map.addLayer({
67 − id: "rings-line", type: "line", source: "rings", slot: "top",
68 − paint: {
69 − "line-color": "#b3202b", "line-opacity": 0.55,
70 − "line-width": 1.4, "line-dasharray": [3, 3],
71 − },
64 + id: "rings-line", type: "line", source: "rings",
65 + paint: { "line-color": "#b3202b", "line-opacity": 0.55,
66 + "line-width": 1.4, "line-dasharray": [3, 3] },
72 67 });
73 68 });
74 69
75 − // marqueur-prix DOM (pastille + halo pulsant + point)
70 + // marqueur maison : halo pulsant + pastille de prix
76 71 const el = document.createElement("div");
77 72 el.className = "pm-marker";
78 − el.innerHTML =
79 − `<div class="pm-pill${deal ? " pm-pill-deal" : ""}">${price != null ? formatCompactPrice(price) : "•"}${deal ? " ↓" : ""}</div>` +
80 − `<div class="pm-pulse"></div><div class="pm-dot"></div>`;
81 − const marker = new mapboxgl.Marker({ element: el, anchor: "bottom" })
82 − .setLngLat([lng, lat])
83 − .addTo(map);
73 + el.innerHTML = `
74 + <div class="pm-pill${deal ? " pm-pill-deal" : ""}">${shortPrice(price)}${deal ? " ↓" : ""}</div>
75 + <div class="pm-pulse"></div>
76 + <div class="pm-dot"></div>`;
77 + const marker = new maplibregl.Marker({ element: el, anchor: "bottom" })
78 + .setLngLat([lng, lat]).addTo(map);
84 79
85 − return () => {
86 − marker.remove();
87 − map.remove();
88 − };
80 + return () => { marker.remove(); map.remove(); };
89 81 }, [lat, lng, price, deal]);
90 82
91 83 return (
92 84 <div className="pm-wrap">
93 − <div ref={div} style={{ position: "absolute", inset: 0 }} role="img" aria-label="Carte de la propriété" />
85 + <div ref={div} className="mapview" role="img" aria-label="Emplacement de la propriété" />
94 86 <div className="pm-rings-legend" aria-hidden="true">
95 − <span>◌ 5 min à pied</span>
96 − <span>◌ 15 min à pied</span>
87 + <span>◌ 5 min à pied</span><span>◌ 15 min à pied</span>
97 88 </div>
89 + <div className="mv-credit" aria-hidden="true">Cartographie <b>Groupe&nbsp;Ka</b></div>
98 90 </div>
99 91 );
100 92 }
added frontend/src/map/immokaStyle.ts +235 −0
@@ -0,0 +1,235 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// map/immokaStyle.ts : style cartographique « Groupe Ka », écrit FROM SCRATCH
5 +// sur le schéma OpenMapTiles (tuiles vectorielles libres OpenFreeMap).
6 +// Direction « éditoriale contrastée » : crème chaud, eau bleue franche,
7 +// forêts vertes, autoroutes rouges (la marque), primaires jaune Michelin,
8 +// relief au zoom province — une carte de magazine, pas un fond gris.
9 +// -----------------------------------------------------------------------------
10 +import type { StyleSpecification } from "maplibre-gl";
11 +
12 +// ---- palette éditoriale -----------------------------------------------------
13 +const PAPER = "#f6efe6"; // crème chaud
14 +const WATER = "#a6c7dd"; // eau bleue franche
15 +const WATER_DEEP = "#97bcd6"; // océan / grands plans d'eau
16 +const GRASS = "#dce8c6"; // prés / cultures
17 +const WOOD = "#c4dcae"; // forêts — vert affirmé
18 +const PARK = "#cbe3b3"; // parcs
19 +const RESID = "#efe2d2"; // trame urbaine sable
20 +const INDUS = "#e9e0e2"; // industriel mauve-gris
21 +const SAND = "#f3e8c9";
22 +const BUILD = "#e2cdbd"; // bâtiments terracotta pâle
23 +const BUILD_LINE = "#d2b7a4";
24 +const MOTOR = "#e0584c"; // autoroutes — ROUGE Immo-Ka
25 +const MOTOR_CASE = "#ad342d";
26 +const PRIM = "#f6d17c"; // primaires — jaune éditorial
27 +const PRIM_CASE = "#cfa14a";
28 +const ROAD = "#ffffff";
29 +const ROAD_CASE = "#cfbcab";
30 +const MINOR_CASE = "#dccbba";
31 +const RAIL = "#a29191";
32 +const BOUND = "#a77f8b";
33 +const INK = "#2a2024"; // étiquettes principales
34 +const INK_SOFT = "#6d5d60";
35 +const WATER_INK = "#3f6f92";
36 +const GREEN_INK = "#5c7a45";
37 +
38 +const style: StyleSpecification = {
39 + version: 8,
40 + name: "Immo-Ka · Groupe Ka (éditorial)",
41 + glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
42 + sources: {
43 + omt: { type: "vector", url: "https://tiles.openfreemap.org/planet" },
44 + relief: {
45 + type: "raster", maxzoom: 6, tileSize: 256,
46 + tiles: ["https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png"],
47 + },
48 + },
49 + layers: [
50 + { id: "bg", type: "background", paint: { "background-color": PAPER } },
51 +
52 + // relief léger au zoom province — donne du corps à la vue d'ensemble
53 + { id: "relief", type: "raster", source: "relief", maxzoom: 7,
54 + paint: { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.55, 5, 0.45, 6.5, 0] } },
55 +
56 + // --- occupation du sol -------------------------------------------------
57 + { id: "landcover-grass", type: "fill", source: "omt", "source-layer": "landcover",
58 + filter: ["in", ["get", "class"], ["literal", ["grass", "farmland"]]],
59 + paint: { "fill-color": GRASS, "fill-opacity": 0.85 } },
60 + { id: "landcover-wetland", type: "fill", source: "omt", "source-layer": "landcover",
61 + filter: ["==", ["get", "class"], "wetland"],
62 + paint: { "fill-color": "#cfe0cf", "fill-opacity": 0.7 } },
63 + { id: "landcover-wood", type: "fill", source: "omt", "source-layer": "landcover",
64 + filter: ["==", ["get", "class"], "wood"],
65 + paint: { "fill-color": WOOD,
66 + "fill-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0.7, 11, 0.9] } },
67 + { id: "landcover-sand", type: "fill", source: "omt", "source-layer": "landcover",
68 + filter: ["==", ["get", "class"], "sand"], paint: { "fill-color": SAND } },
69 + { id: "landcover-ice", type: "fill", source: "omt", "source-layer": "landcover",
70 + filter: ["==", ["get", "class"], "ice"], paint: { "fill-color": "#eef4f6" } },
71 + { id: "landuse-residential", type: "fill", source: "omt", "source-layer": "landuse",
72 + filter: ["in", ["get", "class"], ["literal", ["residential", "suburb", "neighbourhood"]]],
73 + paint: { "fill-color": RESID,
74 + "fill-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 12, 0.9] } },
75 + { id: "landuse-indus", type: "fill", source: "omt", "source-layer": "landuse",
76 + filter: ["in", ["get", "class"], ["literal", ["industrial", "commercial", "quarry", "railway"]]],
77 + paint: { "fill-color": INDUS, "fill-opacity": 0.7 } },
78 + { id: "landuse-green", type: "fill", source: "omt", "source-layer": "landuse",
79 + filter: ["in", ["get", "class"], ["literal", ["cemetery", "stadium", "pitch", "playground"]]],
80 + paint: { "fill-color": PARK, "fill-opacity": 0.6 } },
81 + { id: "park", type: "fill", source: "omt", "source-layer": "park",
82 + paint: { "fill-color": PARK, "fill-opacity": 0.75 } },
83 +
84 + // --- eau ---------------------------------------------------------------
85 + { id: "waterway", type: "line", source: "omt", "source-layer": "waterway",
86 + paint: { "line-color": WATER,
87 + "line-width": ["interpolate", ["exponential", 1.4], ["zoom"], 8, 0.8, 14, 3, 18, 8] } },
88 + { id: "water", type: "fill", source: "omt", "source-layer": "water",
89 + paint: { "fill-color": ["case", ["==", ["get", "class"], "ocean"], WATER_DEEP, WATER] } },
90 +
91 + // --- aéroports ----------------------------------------------------------
92 + { id: "aeroway", type: "line", source: "omt", "source-layer": "aeroway",
93 + minzoom: 11, filter: ["in", ["get", "class"], ["literal", ["runway", "taxiway"]]],
94 + paint: { "line-color": "#e6d9d2",
95 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1, 16, 26] } },
96 +
97 + // --- tunnels --------------------------------------------------------------
98 + { id: "tunnel", type: "line", source: "omt", "source-layer": "transportation",
99 + filter: ["==", ["get", "brunnel"], "tunnel"],
100 + paint: { "line-color": ROAD_CASE, "line-dasharray": [2, 2],
101 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 8, 0.8, 14, 3.5, 18, 12] } },
102 +
103 + // --- voirie : liserés (du bas vers le haut de la hiérarchie) ---------------
104 + { id: "road-minor-case", type: "line", source: "omt", "source-layer": "transportation",
105 + minzoom: 11,
106 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
107 + ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]]],
108 + layout: { "line-cap": "round", "line-join": "round" },
109 + paint: { "line-color": MINOR_CASE,
110 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1.2, 15, 5, 18, 16] } },
111 + { id: "road-mid-case", type: "line", source: "omt", "source-layer": "transportation",
112 + minzoom: 9,
113 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
114 + ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]],
115 + layout: { "line-cap": "round", "line-join": "round" },
116 + paint: { "line-color": ROAD_CASE,
117 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 2, 14, 7, 18, 22] } },
118 + { id: "road-prim-case", type: "line", source: "omt", "source-layer": "transportation",
119 + minzoom: 7,
120 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
121 + ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]],
122 + layout: { "line-cap": "round", "line-join": "round" },
123 + paint: { "line-color": PRIM_CASE,
124 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.8, 14, 8, 18, 26] } },
125 + { id: "motorway-case", type: "line", source: "omt", "source-layer": "transportation",
126 + minzoom: 5, filter: ["==", ["get", "class"], "motorway"],
127 + layout: { "line-cap": "round", "line-join": "round" },
128 + paint: { "line-color": MOTOR_CASE,
129 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 1.4, 14, 9, 18, 28] } },
130 +
131 + // --- voirie : remplissages ---------------------------------------------------
132 + { id: "road-path", type: "line", source: "omt", "source-layer": "transportation",
133 + minzoom: 14, filter: ["==", ["get", "class"], "path"],
134 + paint: { "line-color": "#b8a394", "line-dasharray": [2.5, 2],
135 + "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0.8, 18, 2.5] } },
136 + { id: "road-minor", type: "line", source: "omt", "source-layer": "transportation",
137 + minzoom: 11,
138 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
139 + ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]]],
140 + layout: { "line-cap": "round", "line-join": "round" },
141 + paint: { "line-color": ROAD,
142 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 0.7, 15, 3.8, 18, 13] } },
143 + { id: "road-mid", type: "line", source: "omt", "source-layer": "transportation",
144 + minzoom: 9,
145 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
146 + ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]],
147 + layout: { "line-cap": "round", "line-join": "round" },
148 + paint: { "line-color": ROAD,
149 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 1.3, 14, 5.5, 18, 18] } },
150 + { id: "road-prim", type: "line", source: "omt", "source-layer": "transportation",
151 + minzoom: 7,
152 + filter: ["all", ["!=", ["get", "brunnel"], "tunnel"],
153 + ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]],
154 + layout: { "line-cap": "round", "line-join": "round" },
155 + paint: { "line-color": PRIM,
156 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.1, 14, 6.5, 18, 22] } },
157 + { id: "motorway", type: "line", source: "omt", "source-layer": "transportation",
158 + minzoom: 5, filter: ["==", ["get", "class"], "motorway"],
159 + layout: { "line-cap": "round", "line-join": "round" },
160 + paint: { "line-color": MOTOR,
161 + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0.9, 14, 7, 18, 24] } },
162 + { id: "rail", type: "line", source: "omt", "source-layer": "transportation",
163 + minzoom: 10, filter: ["==", ["get", "class"], "rail"],
164 + paint: { "line-color": RAIL, "line-dasharray": [3, 3],
165 + "line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.7, 16, 2.2] } },
166 +
167 + // --- ponts (léger liseré pour lecture) -----------------------------------------
168 + { id: "bridge-case", type: "line", source: "omt", "source-layer": "transportation",
169 + minzoom: 12, filter: ["==", ["get", "brunnel"], "bridge"],
170 + paint: { "line-color": "#b7a08e", "line-gap-width":
171 + ["interpolate", ["exponential", 1.5], ["zoom"], 12, 3, 18, 20], "line-width": 1.2 } },
172 +
173 + // --- bâtiments -------------------------------------------------------------------
174 + { id: "building", type: "fill", source: "omt", "source-layer": "building",
175 + minzoom: 13,
176 + paint: { "fill-color": BUILD, "fill-outline-color": BUILD_LINE,
177 + "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0.55, 16, 0.95] } },
178 +
179 + // --- frontières -----------------------------------------------------------------
180 + { id: "boundary-prov", type: "line", source: "omt", "source-layer": "boundary",
181 + filter: ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "maritime"], 1]],
182 + paint: { "line-color": BOUND, "line-dasharray": [4, 3],
183 + "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.6] } },
184 + { id: "boundary-country", type: "line", source: "omt", "source-layer": "boundary",
185 + filter: ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "maritime"], 1]],
186 + paint: { "line-color": BOUND,
187 + "line-width": ["interpolate", ["linear"], ["zoom"], 3, 1.2, 10, 2.6] } },
188 +
189 + // --- étiquettes --------------------------------------------------------------------
190 + { id: "park-label", type: "symbol", source: "omt", "source-layer": "park",
191 + minzoom: 11, filter: ["==", ["geometry-type"], "Point"],
192 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
193 + "text-font": ["Noto Sans Italic"], "text-size": 11.5 },
194 + paint: { "text-color": GREEN_INK, "text-halo-color": PAPER, "text-halo-width": 1.4 } },
195 + { id: "water-label", type: "symbol", source: "omt", "source-layer": "water_name",
196 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
197 + "text-font": ["Noto Sans Italic"],
198 + "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 12, 14] },
199 + paint: { "text-color": WATER_INK, "text-halo-color": "rgba(246,239,230,0.75)",
200 + "text-halo-width": 1.2 } },
201 + { id: "road-label", type: "symbol", source: "omt", "source-layer": "transportation_name",
202 + minzoom: 12.5,
203 + layout: { "symbol-placement": "line",
204 + "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
205 + "text-font": ["Noto Sans Regular"], "text-size": 11.5 },
206 + paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.6 } },
207 + { id: "place-village", type: "symbol", source: "omt", "source-layer": "place",
208 + minzoom: 9,
209 + filter: ["in", ["get", "class"], ["literal", ["village", "suburb", "neighbourhood", "hamlet"]]],
210 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
211 + "text-font": ["Noto Sans Regular"],
212 + "text-size": ["interpolate", ["linear"], ["zoom"], 9, 11, 14, 13.5] },
213 + paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.7 } },
214 + { id: "place-town", type: "symbol", source: "omt", "source-layer": "place",
215 + filter: ["==", ["get", "class"], "town"],
216 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
217 + "text-font": ["Noto Sans Bold"],
218 + "text-size": ["interpolate", ["linear"], ["zoom"], 6, 12, 12, 16] },
219 + paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 1.8 } },
220 + { id: "place-city", type: "symbol", source: "omt", "source-layer": "place",
221 + filter: ["==", ["get", "class"], "city"],
222 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
223 + "text-font": ["Noto Sans Bold"],
224 + "text-size": ["interpolate", ["linear"], ["zoom"], 4, 13, 10, 19] },
225 + paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 2 } },
226 + { id: "place-state", type: "symbol", source: "omt", "source-layer": "place",
227 + maxzoom: 7, filter: ["==", ["get", "class"], "state"],
228 + layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]],
229 + "text-font": ["Noto Sans Regular"], "text-size": 11.5,
230 + "text-transform": "uppercase", "text-letter-spacing": 0.18 },
231 + paint: { "text-color": BOUND, "text-halo-color": PAPER, "text-halo-width": 1.5 } },
232 + ],
233 +};
234 +
235 +export default style;
added frontend/src/map/pills.ts +89 −0
@@ -0,0 +1,89 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// map/pills.ts : pastilles de prix pour la carte — images « étirables »
5 +// (stretchable images MapLibre) générées au canvas : la pastille s'adapte
6 +// à la largeur du texte (icon-text-fit) sans aucun sprite externe.
7 +// -----------------------------------------------------------------------------
8 +import type { Map as MLMap, StyleImageMetadata } from "maplibre-gl";
9 +
10 +const R = 13; // rayon des coins (px logiques)
11 +const H = 30; // hauteur de la pastille
12 +const W = 64; // largeur de base (étirée au besoin)
13 +const DPR = 2; // netteté écrans Retina
14 +
15 +/** roundRect avec repli manuel (Safari < 16 ne l'a pas). */
16 +function rr(g: CanvasRenderingContext2D, x: number, y: number,
17 + w: number, h: number, r: number): void {
18 + if (typeof (g as any).roundRect === "function") {
19 + (g as any).roundRect(x, y, w, h, r);
20 + return;
21 + }
22 + g.moveTo(x + r, y);
23 + g.arcTo(x + w, y, x + w, y + h, r);
24 + g.arcTo(x + w, y + h, x, y + h, r);
25 + g.arcTo(x, y + h, x, y, r);
26 + g.arcTo(x, y, x + w, y, r);
27 + g.closePath();
28 +}
29 +
30 +function drawPill(fill: string, stroke: string, shadow = true): ImageData {
31 + const cv = document.createElement("canvas");
32 + cv.width = W * DPR;
33 + cv.height = (H + 6) * DPR; // marge pour l'ombre + la pointe
34 + const g = cv.getContext("2d")!;
35 + g.scale(DPR, DPR);
36 + if (shadow) {
37 + g.shadowColor = "rgba(26,18,20,0.28)";
38 + g.shadowBlur = 5;
39 + g.shadowOffsetY = 2;
40 + }
41 + // corps arrondi
42 + g.beginPath();
43 + rr(g, 1, 1, W - 2, H - 2, R);
44 + g.fillStyle = fill;
45 + g.fill();
46 + g.shadowColor = "transparent";
47 + // petite pointe sous la pastille (ancrage visuel sur la propriété)
48 + g.beginPath();
49 + g.moveTo(W / 2 - 5, H - 1.5);
50 + g.lineTo(W / 2, H + 4);
51 + g.lineTo(W / 2 + 5, H - 1.5);
52 + g.closePath();
53 + g.fillStyle = fill;
54 + g.fill();
55 + g.strokeStyle = stroke;
56 + g.lineWidth = 1.4;
57 + g.beginPath();
58 + rr(g, 1, 1, W - 2, H - 2, R);
59 + g.stroke();
60 + return g.getImageData(0, 0, cv.width, cv.height);
61 +}
62 +
63 +/** Enregistre les variantes de pastille dans la carte. */
64 +export function addPills(map: MLMap): void {
65 + const meta: Partial<StyleImageMetadata> = {
66 + pixelRatio: DPR,
67 + // zones étirables entre les coins arrondis — coordonnées PHYSIQUES (× DPR)
68 + // et non dégénérées (x1 < x2), sinon MapLibre rejette l'image en silence
69 + stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]],
70 + stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]],
71 + // zone où le texte peut vivre
72 + content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR],
73 + };
74 + if (!map.hasImage("pill"))
75 + map.addImage("pill", drawPill("#ffffff", "rgba(26,18,20,0.35)"), meta);
76 + if (!map.hasImage("pill-deal"))
77 + map.addImage("pill-deal", drawPill("#e23744", "#b3202b"), meta);
78 + if (!map.hasImage("pill-active"))
79 + map.addImage("pill-active", drawPill("#1a1214", "#1a1214"), meta);
80 +}
81 +
82 +/** « 425 k$ », « 1,25 M$ » — format compact des pastilles. */
83 +export function shortPrice(p: number | null | undefined): string {
84 + if (p == null) return "•";
85 + if (p >= 1_000_000)
86 + return `${(p / 1_000_000).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} M$`;
87 + if (p >= 10_000) return `${Math.round(p / 1000)} k$`;
88 + return `${p.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`;
89 +}
modified frontend/src/pages/Home.tsx +6 −44
@@ -8,7 +8,7 @@ import { useSearchParams } from "react-router-dom";
8 8 import {
9 9 Facets, Listing, ListingFilters, Stats,
10 10 fetchFacets, fetchListings, fetchSources, fetchStats,
11 − registerSourceNames, setDocTitle, sourceName,
11 + registerSourceNames, sourceName,
12 12 } from "../api";
13 13 import ListingCard from "../components/ListingCard";
14 14 import { Ico } from "../components/Icons";
@@ -34,18 +34,7 @@ function pageNumbers(p: number, n: number): (number | "…")[] {
34 34 return out;
35 35 }
36 36
37 −/** Valeur retardée : évite une requête API à chaque frappe de recherche. */
38 −function useDebounced<T>(value: T, delayMs = 300): T {
39 − const [debounced, setDebounced] = useState(value);
40 − useEffect(() => {
41 − const t = setTimeout(() => setDebounced(value), delayMs);
42 − return () => clearTimeout(t);
43 − }, [value, delayMs]);
44 − return debounced;
45 −}
46 −
47 37 export default function Home() {
48 − useEffect(() => { setDocTitle(); }, []); // titre par défaut (navigation SPA)
49 38 const [listings, setListings] = useState<Listing[] | null>(null);
50 39 const [total, setTotal] = useState(0);
51 40 const [params, setParams] = useSearchParams();
@@ -74,23 +63,11 @@ export default function Home() {
74 63 const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");
75 64 useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);
76 65
77 − const qDebounced = useDebounced(q);
78 66 const filters: ListingFilters = useMemo(() => ({
79 − q: qDebounced, city, sector, region: "", property_type: ptype, source,
67 + q, city, sector, region: "", property_type: ptype, source,
80 68 price_min: priceMin, price_max: priceMax,
81 69 bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort,
82 − }), [qDebounced, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);
83 −
84 − // sélection carte ↔ liste (vue carte) — Immo-Ka Maps (framework Ka Maps)
85 − const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null);
86 − const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null);
87 − const mapListRef = useRef<HTMLDivElement | null>(null);
88 − useEffect(() => {
89 − if (!mapSelectedUid) return;
90 − mapListRef.current
91 − ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`)
92 − ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
93 − }, [mapSelectedUid]);
70 + }), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);
94 71
95 72 useEffect(() => {
96 73 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -366,26 +343,11 @@ export default function Home() {
366 343
367 344 {!error && view === "carte" && (
368 345 <div className="map-split">
369 − <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}>
370 − {(listings ?? []).map((l) => (
371 − <div
372 − key={l.uid}
373 − data-uid={l.uid}
374 − className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`}
375 − onMouseEnter={() => setMapHoveredUid(l.uid)}
376 − onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))}
377 − >
378 − <ListingCard l={l} />
379 − </div>
380 − ))}
346 + <div className="map-list" aria-label="Résultats en liste">
347 + {(listings ?? []).map((l) => <ListingCard key={l.uid} l={l} />)}
381 348 </div>
382 349 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
383 − <MapView
384 − filters={filters}
385 − selectedUid={mapSelectedUid}
386 − hoveredUid={mapHoveredUid}
387 − onSelect={setMapSelectedUid}
388 − />
350 + <MapView filters={filters} />
389 351 </Suspense>
390 352 </div>
391 353 )}
modified frontend/src/pages/Listing.tsx +4 −19
@@ -9,13 +9,12 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react";
9 9 import { Link, useParams } from "react-router-dom";
10 10 import {
11 11 Listing, Room, fetchListing, fetchSources, fmtArea, fmtDate, fmtPrice,
12 − registerSourceNames, setDocTitle, sourceName,
12 + registerSourceNames, sourceName,
13 13 } from "../api";
14 14
15 15 const PropertyMap = lazy(() => import("../components/PropertyMap"));
16 16 import QuartierBlock from "../components/QuartierBlock";
17 −import { useAccount } from "../account";
18 −import { Ico, IcoHeart } from "../components/Icons";
17 +import { Ico } from "../components/Icons";
19 18
20 19 // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
21 20 function Galerie({ images, titre }: { images: string[]; titre: string }) {
@@ -92,18 +91,12 @@ export default function ListingPage() {
92 91 const { uid } = useParams<{ uid: string }>();
93 92 const [l, setL] = useState<Listing | null>(null);
94 93 const [error, setError] = useState<string | null>(null);
95 − const { favs, toggleFav } = useAccount();
96 94
97 95 useEffect(() => {
98 96 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
99 97 if (!uid) return;
100 98 setL(null); setError(null);
101 − fetchListing(uid)
102 − .then((d) => {
103 − setL(d);
104 − setDocTitle(`${d.address || d.title} — ${d.property_type || "Propriété"} à vendre, ${d.city || "Québec"}`);
105 − })
106 − .catch((e) => setError(String(e)));
99 + fetchListing(uid).then(setL).catch((e) => setError(String(e)));
107 100 window.scrollTo(0, 0);
108 101 }, [uid]);
109 102
@@ -157,16 +150,8 @@ export default function ListingPage() {
157 150 <div className="fiche">
158 151 {/* -------- colonne gauche : galerie, description, caractéristiques ---- */}
159 152 <div className="f-col">
160 − <section className="f-bloc f-galerie" aria-label="Photos" style={{ position: "relative" }}>
153 + <section className="f-bloc f-galerie" aria-label="Photos">
161 154 <Galerie images={l.images ?? []} titre={l.address || l.title} />
162 − <button
163 − className={`fav-btn fav-fiche ${favs.has(l.uid) ? "on" : ""}`}
164 − aria-label={favs.has(l.uid) ? "Retirer des favoris" : "Ajouter aux favoris"}
165 − aria-pressed={favs.has(l.uid)}
166 − onClick={() => toggleFav(l)}
167 − >
168 − <IcoHeart size={19} filled={favs.has(l.uid)} />
169 − </button>
170 155 </section>
171 156
172 157 {l.description && (
modified frontend/src/styles.css +1 −184
@@ -179,20 +179,6 @@ img { display: block; }
179 179 .card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
180 180 .card:hover .card-img img { transform: scale(1.05); }
181 181 .card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
182 −
183 −/* ---- favoris ♥ (magasin central au hub Groupe KA) — cœur rond en coin de photo */
184 −.fav-btn {
185 − position: absolute; right: 10px; bottom: 10px; z-index: 2;
186 − width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
187 − background: rgba(255, 255, 255, 0.94); border: 1.5px solid var(--ink);
188 − border-radius: 999px; cursor: pointer; color: var(--ink); padding: 0;
189 − transition: transform 0.12s ease, background 0.12s ease;
190 −}
191 −.fav-btn:hover { transform: scale(1.12); }
192 −.fav-btn.on { background: var(--lime-soft); color: var(--lime); }
193 −.fav-btn.on svg { filter: drop-shadow(0 0 3px rgba(226, 55, 68, 0.35)); }
194 −/* variante fiche : par-dessus la galerie, coin supérieur droit */
195 −.fav-btn.fav-fiche { top: 14px; right: 14px; bottom: auto; width: 42px; height: 42px; z-index: 5; }
196 182 .badge { position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); }
197 183 .badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
198 184 .badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
@@ -486,13 +472,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
486 472 .mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
487 473 .mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
488 474 .mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); }
489 −@media (max-width: 760px) {
490 − .menu-btn { display: flex; }
491 − .nav { display: none; }
492 − .mobile-menu { display: block; }
493 − .auth-slot { margin-left: auto; margin-right: 10px; }
494 − .auth-btn { padding: 8px 12px; min-height: 36px; font-size: 12.5px; }
495 −}
475 +@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } .mobile-menu { display: block; } }
496 476
497 477 /* ================= Footer ================= */
498 478 .footer { background: var(--ink); color: rgba(247, 241, 239, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; }
@@ -649,166 +629,3 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
649 629 font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff;
650 630 text-decoration: none; }
651 631 .vp-link:hover { background: var(--green); }
652 −
653 −/* --- Pages légales (Conditions / Confidentialité) & liens du footer -------- */
654 −.footer .flegal { margin-top: 16px; font-size: 12.5px; }
655 −.footer .flegal a { color: rgba(247, 241, 239, 0.75); text-decoration: underline; text-underline-offset: 3px; }
656 −.footer .flegal a:hover { color: var(--lime); }
657 −.footer .fmono a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
658 −.legal { max-width: 760px; padding-top: 34px; padding-bottom: 60px; }
659 −.legal h1 { font-family: var(--font-display); font-size: clamp(28px, 5vw, 40px); letter-spacing: -0.03em; margin: 0 0 6px; }
660 −.legal .legal-meta { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 26px; }
661 −.legal h2 { font-size: 17px; margin: 26px 0 6px; }
662 −.legal p { color: var(--ink-2); line-height: 1.65; margin: 0 0 10px; }
663 −.legal a { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
664 −
665 −/* --- Compte Groupe-Ka (connexion Google) ----------------------------------- */
666 −.auth-btn { padding: 9px 15px; border-radius: 999px; font-weight: 800; font-size: 13.5px;
667 − background: #141814; color: #f5f3ee; border: 1.5px solid #141814;
668 − min-height: 40px; display: inline-flex; align-items: center; gap: 8px;
669 − letter-spacing: 0.02em; white-space: nowrap; }
670 −.auth-btn:hover { background: #000; color: #d9f26b; }
671 −.auth-kab { display: inline-flex; align-items: center; justify-content: center;
672 − width: 22px; height: 18px; border-radius: 4px; background: #d9f26b; color: #141814;
673 − font-size: 10px; font-weight: 800; letter-spacing: -0.02em; transform: rotate(-2deg); flex: none; }
674 −.auth-slot { margin-left: 10px; display: inline-flex; align-items: center; }
675 −.auth-user { position: relative; display: inline-flex; }
676 −.auth-avatar { width: 40px; height: 40px; border-radius: 999px; border: 2px solid var(--ink);
677 − padding: 0; overflow: hidden; cursor: pointer; background: var(--lime);
678 − display: inline-flex; align-items: center; justify-content: center;
679 − font-weight: 800; font-size: 16px; color: var(--ink); }
680 −.auth-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
681 −.auth-menu { position: absolute; right: 0; top: calc(100% + 10px); z-index: 60;
682 − background: var(--surface); border: 2px solid var(--ink); border-radius: 12px;
683 − box-shadow: var(--shadow-off-soft); min-width: 230px; padding: 12px; }
684 −.auth-menu .auth-id { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; }
685 −.auth-menu .auth-id span { color: var(--ink-2); font-size: 12px; word-break: break-all; }
686 −.auth-menu .auth-note { font-family: var(--font-mono); font-size: 10px;
687 − text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3);
688 − margin: 8px 0 10px; }
689 −.auth-menu button { width: 100%; padding: 8px 10px; border-radius: 8px;
690 − border: 1.5px solid var(--ink); background: transparent; font-weight: 700;
691 − font-size: 13px; cursor: pointer; color: var(--ink); }
692 −.auth-menu button:hover { background: var(--ink); color: var(--lime); }
693 −.auth-menu-link { display: block; width: 100%; box-sizing: border-box;
694 − padding: 8px 10px; margin-bottom: 8px; border-radius: 8px;
695 − border: 1.5px solid var(--ink); background: transparent; font-weight: 700;
696 − font-size: 13px; color: var(--ink); text-align: center; }
697 −.auth-menu-link:hover { background: var(--ink); color: var(--lime); }
698 −
699 −/* ================= Profil — carte de membre Groupe KA =================
700 − La carte porte l'IDENTITÉ DU GROUPE (encre #141814 + lime #d9f26b),
701 − volontairement distincte du thème rouge d'Immo-Ka : même carte sur
702 − toutes les plateformes du Groupe KA. */
703 −:root { --ka-ink: #141814; --ka-lime: #d9f26b; --ka-paper: #f5f3ee; }
704 −
705 −.profil { padding-top: 40px; padding-bottom: 60px; }
706 −.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; }
707 −.profil h1 .hl { background: var(--lime); color: #fff; padding: 0 8px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
708 −.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; font-size: 16px; }
709 −
710 −.pc {
711 − position: relative; max-width: 520px; overflow: hidden;
712 − background: var(--ka-ink); color: var(--ka-paper);
713 − border: 2px solid var(--ka-ink); border-radius: 16px;
714 − padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.18);
715 −}
716 −.pc-watermark {
717 − position: absolute; right: -18px; top: -34px; pointer-events: none;
718 − font-family: var(--font-display); font-weight: 700; font-size: 170px;
719 − letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07);
720 − transform: rotate(-8deg); line-height: 1;
721 −}
722 −.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
723 −.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; }
724 −.pc-ka { background: var(--ka-lime); color: var(--ka-ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; display: inline-block; transform: rotate(-2deg); }
725 −.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.55); }
726 −.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; }
727 −.pc-id-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(217, 242, 107, 0.65); }
728 −.pc-id {
729 − font-family: var(--font-mono); font-weight: 700; color: var(--ka-lime);
730 − font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em;
731 − text-shadow: 0 0 24px rgba(217, 242, 107, 0.35);
732 −}
733 −.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; }
734 −.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
735 −.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
736 −.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; }
737 −.pc-role-badge {
738 − display: inline-block; width: fit-content; margin: 3px 0 2px;
739 − background: var(--ka-lime); color: var(--ka-ink);
740 − font-family: var(--font-mono); font-size: 9px; font-weight: 700;
741 − text-transform: uppercase; letter-spacing: 0.12em;
742 − padding: 2px 7px 3px; border-radius: 5px; transform: rotate(-1deg);
743 −}
744 −.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--ka-lime); flex: none; object-fit: cover; }
745 −.pc-strip { display: flex; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(217, 242, 107, 0.1); border-top: 1px solid rgba(217, 242, 107, 0.25); overflow: hidden; }
746 −.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--ka-lime); opacity: 0.7; }
747 −.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; }
748 −.pc-strip i:nth-child(3n+1) { height: 9px; }
749 −.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; }
750 −.pc-copy { margin-top: 16px; }
751 −.pc-copy.ok { background: var(--ka-lime); border-color: var(--ka-ink); color: var(--ka-ink); }
752 −
753 −.profil-grid {
754 − display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
755 − gap: 12px; margin-top: 30px; max-width: 760px;
756 −}
757 −.pg-item {
758 − background: var(--surface); border: 1.5px solid var(--ink);
759 − border-radius: var(--r-card); padding: 14px 16px;
760 − display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat);
761 −}
762 −.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); }
763 −.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; }
764 −.pg-value.mono { font-family: var(--font-mono); color: var(--green-deep); }
765 −
766 −/* profil Groupe KA — lecture seule (source de vérité : groupe-ka.com/compte) */
767 −.hub-profile { margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); max-width: 640px; display: flex; flex-direction: column; gap: 14px; }
768 −.hub-profile h3 { font-size: 18px; margin: 0; }
769 −.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; }
770 −.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
771 −.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; }
772 −.hub-profile a.stat-chip { color: var(--ink); text-decoration: none; }
773 −.hub-profile a.stat-chip:hover { background: var(--lime-soft); }
774 −.hub-profile .btn-primary { display: inline-flex; align-items: center; gap: 7px; }
775 −.hub-hint { color: var(--ink-3); font-size: 12.5px; margin: 0; }
776 −
777 −.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; }
778 −.profil-note a { text-decoration: underline; text-underline-offset: 3px; }
779 −.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; }
780 −.profil-actions .btn { display: inline-flex; align-items: center; gap: 7px; }
781 −
782 −/* -----------------------------------------------------------------------------
783 − Immo-Ka Maps (framework Ka Maps par Groupe Ka)
784 − Jetons du design system appliqués au chrome de la carte + sync liste↔carte.
785 −----------------------------------------------------------------------------- */
786 −.mapview .ka-map {
787 − --ka-accent: #e23744;
788 − --ka-on-accent: #ffffff;
789 − --ka-surface: var(--surface);
790 − --ka-ink: var(--ink);
791 − --ka-line: var(--line-strong);
792 − --ka-radius: var(--r-ctl);
793 − --ka-shadow: 4px 4px 0 rgba(26, 18, 20, 0.18);
794 − --ka-font: var(--font-body);
795 −}
796 −.mapview { position: relative; overflow: hidden; }
797 −.mapview .ka-map { position: absolute; inset: 0; }
798 −.ka-preview .mv-pop img, .ka-preview .mv-noimg { height: 120px; width: 100%; object-fit: cover; }
799 −.ka-preview-sheet .mv-pop { display: grid; grid-template-columns: 130px 1fr; }
800 −.ka-preview-sheet .mv-pop img, .ka-preview-sheet .mv-noimg { height: 100%; min-height: 120px; }
801 −.mv-pop-deal {
802 − display: inline-block; margin-top: 2px; padding: 2px 8px;
803 − background: var(--lime-soft); color: var(--green-deep);
804 − border-radius: 999px; font-family: var(--font-mono);
805 − font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
806 −}
807 −.mv-legend span { display: inline-flex; align-items: center; gap: 6px; }
808 −.mv-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; }
809 −.mv-dot-ink { background: #1a1214; }
810 −.mv-dot-red { background: #e23744; }
811 −
812 −/* carte de liste synchronisée avec la carte (survol / sélection) */
813 −.map-card { border-radius: var(--r-card); }
814 −.map-card-sel { outline: 2px solid var(--lime); outline-offset: 2px; }
added frontend/styles.css +535 −0
@@ -0,0 +1,535 @@
1 +/* -----------------------------------------------------------------------------
2 + Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : système de design « éditorial sharp » — thème clair
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Accent rouge vif sur encre chocolat · 100 % adaptatif mobile
8 +----------------------------------------------------------------------------- */
9 +:root {
10 + /* Palette ROUGE — distincte de Lou-Ka (lime/vert). Noms de tokens conservés :
11 + `--lime` = accent vif (rouge), `--green` = rouge profond (secondaire). */
12 + --paper: #f7f1ef;
13 + --surface: #ffffff;
14 + --surface-2: #fdf6f5;
15 + --ink: #1a1214; /* encre chocolat/near-black chaud */
16 + --ink-2: #5a4d50;
17 + --ink-3: #9b8b8e;
18 + --line: rgba(26, 18, 20, 0.14);
19 + --line-strong: rgba(26, 18, 20, 0.85);
20 + --green: #b3202b; /* rouge profond (accent secondaire / kicker) */
21 + --green-deep: #7f1620;
22 + --lime: #e23744; /* rouge vif (accent principal) */
23 + --lime-soft: #fbe0e2;
24 + --amber: #e8a33d;
25 + --amber-soft: #fdf3e2;
26 + --danger: #b3202b;
27 + --r-card: 14px;
28 + --r-ctl: 8px;
29 + --shadow-flat: 0 1px 2px rgba(26, 18, 20, 0.06);
30 + --shadow-off: 6px 6px 0 var(--ink);
31 + --shadow-off-soft: 8px 8px 0 rgba(226, 55, 68, 0.14);
32 + --font-display: "Space Grotesk", system-ui, sans-serif;
33 + --font-body: "Inter", system-ui, sans-serif;
34 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
35 +}
36 +
37 +* { box-sizing: border-box; }
38 +html { scroll-behavior: smooth; scroll-padding-top: 76px; }
39 +body {
40 + margin: 0; background: var(--paper); color: var(--ink);
41 + font-family: var(--font-body); font-size: 15px; line-height: 1.55;
42 + -webkit-font-smoothing: antialiased; padding-bottom: env(safe-area-inset-bottom);
43 +}
44 +body::before {
45 + content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
46 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
47 +}
48 +#root { position: relative; z-index: 1; }
49 +
50 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
51 +a { color: inherit; text-decoration: none; }
52 +button { font-family: inherit; }
53 +img { display: block; }
54 +::selection { background: var(--lime); color: #fff; }
55 +
56 +.mono { font-family: var(--font-mono); }
57 +.kicker {
58 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
59 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
60 + display: inline-flex; align-items: center; gap: 8px;
61 +}
62 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
63 +
64 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
65 +@media (max-width: 640px) { .container { padding: 0 16px; } }
66 +
67 +/* ================= Header ================= */
68 +.header {
69 + position: sticky; top: 0; z-index: 50;
70 + background: rgba(247, 241, 239, 0.88);
71 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
72 + border-bottom: 2px solid var(--ink);
73 +}
74 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
75 +.brand { font-family: var(--font-display); font-weight: 700; font-size: 26px; letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; }
76 +.brand .ka { background: var(--ink); color: var(--lime); padding: 2px 7px 4px; border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); transition: transform 0.2s ease; }
77 +.brand:hover .ka { transform: rotate(0deg); }
78 +.brand-tag { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px; }
79 +@media (max-width: 860px) { .brand-tag { display: none; } }
80 +.nav { margin-left: auto; display: flex; gap: 4px; }
81 +.nav a { padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px; color: var(--ink-2); border: 1.5px solid transparent; transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center; }
82 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
83 +.nav a.active { background: var(--ink); color: var(--lime); }
84 +
85 +/* ================= Ticker ================= */
86 +.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(226, 55, 68, 0.25); }
87 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; }
88 +.ticker span { padding: 0 26px; position: relative; }
89 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
90 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
91 +@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } * { transition-duration: 0.01ms !important; } }
92 +
93 +/* ================= Hero ================= */
94 +.hero { padding: 58px 0 22px; }
95 +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
96 +.hero h1 { font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 14px; }
97 +.hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); }
98 +.hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
99 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; }
100 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
101 +.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.12); }
102 +.stat-chip b { color: var(--ink); font-weight: 700; }
103 +.stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; }
104 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(226, 55, 68, 0.4); } }
105 +
106 +/* ================= Filter bar ================= */
107 +.filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
108 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
109 +.f-search { flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2); padding: 0 13px; min-height: 52px; color: var(--ink-2); transition: box-shadow 0.15s ease; }
110 +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
111 +.f-search input { border: none; background: none; outline: none; flex: 1; min-width: 0; font-size: 15px; color: var(--ink); font-family: inherit; }
112 +.f-clear { border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none; display: grid; place-items: center; }
113 +.f-ctl { flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); padding: 7px 12px 6px; min-height: 52px; cursor: pointer; transition: box-shadow 0.15s ease; }
114 +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); }
115 +.f-ctl > span { font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
116 +.f-ctl select { border: none; background: transparent; outline: none; font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 180px; text-overflow: ellipsis; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right center; }
117 +.range-pair { display: flex; align-items: center; gap: 4px; }
118 +.range-pair select { max-width: 92px; }
119 +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
120 +.f-more { flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: all 0.13s ease; }
121 +.f-more:hover { background: var(--lime-soft); }
122 +.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(26, 18, 20,0.22); }
123 +.f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0; animation: adv-in 0.18s ease; }
124 +@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
125 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
126 +.f-group > label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
127 +.f-group-end { justify-content: flex-end; }
128 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
129 +.f-native { border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none; font-family: inherit; min-height: 42px; width: 100%; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
130 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
131 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
132 +.seg button { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2); padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px; cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px; transition: all 0.12s ease; }
133 +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
134 +.seg button:last-child { border-radius: 0 8px 8px 0; }
135 +.seg button:hover { background: var(--lime-soft); color: var(--ink); }
136 +.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; }
137 +
138 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
139 +.pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease; }
140 +.pill:hover { background: var(--lime); color: #fff; }
141 +.pill-x { font-size: 10px; opacity: 0.65; }
142 +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
143 +.pill-clear:hover { background: var(--danger); color: #fff; }
144 +.link-btn { background: none; border: none; padding: 0; color: var(--green); font: inherit; text-decoration: underline; cursor: pointer; }
145 +
146 +.btn { border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; }
147 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
148 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); }
149 +.btn-primary:hover { background: var(--green-deep); }
150 +.btn-ghost { background: transparent; color: var(--ink); }
151 +.btn-ghost:hover { background: var(--lime); color: #fff; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.2); }
152 +
153 +/* ================= Chips ================= */
154 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
155 +.chips::-webkit-scrollbar { display: none; }
156 +.chip { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px; transition: all 0.13s ease; }
157 +.chip:hover { background: var(--lime-soft); transform: translateY(-1px); }
158 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(26, 18, 20,0.2); }
159 +
160 +/* ================= Results ================= */
161 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; }
162 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
163 +.results-head > span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
164 +.results-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; }
165 +.sort-ctl select { border: 1.5px solid var(--line-strong); background: var(--surface); border-radius: var(--r-ctl); padding: 7px 28px 7px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; }
166 +.view-toggle { display: inline-flex; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); box-shadow: var(--shadow-flat); }
167 +.view-toggle button { border: 0; background: transparent; padding: 7px 14px; cursor: pointer; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); }
168 +.view-toggle button + button { border-left: 1.5px solid var(--line-strong); }
169 +.view-toggle button.on { background: var(--ink); color: var(--lime); }
170 +
171 +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 22px; padding-bottom: 20px; }
172 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; } }
173 +.more-wrap { display: flex; justify-content: center; padding: 8px 0 70px; }
174 +
175 +.card { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat); transition: transform 0.16s ease, box-shadow 0.16s ease; }
176 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
177 +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
178 +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
179 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
180 +.card:hover .card-img img { transform: scale(1.05); }
181 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
182 +.badge { position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); }
183 +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
184 +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
185 +.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
186 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
187 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
188 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
189 +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
190 +.card-specs { display: flex; gap: 12px; font-size: 12.5px; color: var(--ink-2); font-family: var(--font-mono); }
191 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
192 +.source-tag { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft); border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 62%; }
193 +.avail { font-size: 11px; color: var(--ink-3); font-weight: 500; text-align: right; font-family: var(--font-mono); }
194 +
195 +/* ================= Skeletons ================= */
196 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
197 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
198 +.skel .sk-img, .skel .sk-line { background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); background-size: 800px 100%; animation: shimmer 1.4s infinite linear; }
199 +.skel .sk-img { aspect-ratio: 16/10.5; }
200 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
201 +.skel .sk-line.short { width: 45%; }
202 +
203 +/* ================= Empty / error ================= */
204 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
205 +.notice .big { font-size: 44px; margin-bottom: 10px; }
206 +.notice h2 { text-transform: uppercase; }
207 +
208 +/* ================= Detail page ================= */
209 +.detail { padding: 30px 0 90px; }
210 +.crumbs { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
211 +.crumbs a { border-bottom: 1.5px solid transparent; }
212 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
213 +
214 +.fiche { display: flex; flex-direction: column; gap: 22px; }
215 +.f-col { display: contents; }
216 +.f-galerie { order: 1; } .f-hero { order: 2; }
217 +.f-desc { order: 3; } #caracteristiques { order: 4; } #pieces { order: 5; }
218 +#inclusions { order: 6; } #carte { order: 7; }
219 +@media (min-width: 900px) {
220 + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
221 + .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
222 + .f-col:last-child { position: sticky; top: 88px; }
223 +}
224 +.f-bloc { min-width: 0; }
225 +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 12px; border-left: 4px solid var(--lime); padding-left: 10px; }
226 +.f-bloc:empty { display: none; }
227 +
228 +/* galerie */
229 +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); background: var(--surface-2); box-shadow: var(--shadow-off-soft); }
230 +.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; }
231 +.carousel-track::-webkit-scrollbar { display: none; }
232 +.carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; }
233 +.carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; }
234 +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(26, 18, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
235 +.carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; }
236 +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
237 +@media (max-width: 640px) { .carousel-nav { display: none; } }
238 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; }
239 +.thumbs button { border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease; }
240 +.thumbs button:hover { transform: translateY(-2px); }
241 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
242 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
243 +@media (max-width: 640px) { .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; } .thumbs::-webkit-scrollbar { display: none; } .thumbs button { flex: 0 0 84px; } }
244 +
245 +/* lightbox */
246 +.lightbox { position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100; display: flex; align-items: center; justify-content: center; cursor: zoom-out; padding: max(16px, env(safe-area-inset-top)) 16px; }
247 +.lightbox img { max-width: 94vw; max-height: 86vh; border-radius: 6px; border: 2px solid var(--lime); object-fit: contain; }
248 +.lb-close { position: absolute; top: 18px; right: 22px; background: none; border: none; color: #fff; font-size: 30px; cursor: pointer; }
249 +.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--lime); font-size: 52px; cursor: pointer; padding: 0 22px; user-select: none; }
250 +.lb-nav.prev { left: 6px; } .lb-nav.next { right: 6px; }
251 +.lb-count { position: absolute; bottom: 24px; color: #c9ccc2; font-family: var(--font-mono); font-size: 13px; }
252 +
253 +/* hero panneau (colonne droite) */
254 +.f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; }
255 +.f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; }
256 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 8px; }
257 +.f-hero .loc { color: var(--ink-2); font-size: 14px; margin-top: 2px; }
258 +.deal-badge { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; border: 1.5px solid var(--line-strong); }
259 +.deal-ok { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
260 +
261 +.specs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin: 18px 0; }
262 +.spec { border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 9px 11px; background: var(--surface-2); }
263 +.spec b { display: block; font-family: var(--font-display); font-size: 16px; }
264 +.spec span { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
265 +
266 +.broker { margin: 16px 0; padding: 14px; border: 1.5px dashed var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); }
267 +.broker-k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); }
268 +.broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; }
269 +.broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
270 +
271 +.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); transition: all 0.14s ease; margin-top: 6px; }
272 +.cta:hover { background: var(--lime); color: #fff; }
273 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
274 +.f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
275 +
276 +.desc-text { color: var(--ink-2); font-size: 14.5px; white-space: pre-line; line-height: 1.6; margin: 0; }
277 +
278 +/* tableau caractéristiques */
279 +.dtable { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 22px; }
280 +.drow { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
281 +.drow span { color: var(--ink-3); } .drow b { text-align: right; font-family: var(--font-display); }
282 +
283 +/* tableau pièces */
284 +.rooms-wrap { overflow-x: auto; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); }
285 +table.rooms { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 460px; }
286 +table.rooms th { text-align: left; background: var(--ink); color: var(--paper); padding: 9px 12px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
287 +table.rooms td { padding: 8px 12px; border-bottom: 1px solid var(--line); }
288 +table.rooms tr:last-child td { border-bottom: none; }
289 +table.rooms tr:nth-child(even) td { background: var(--surface-2); }
290 +
291 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; }
292 +.amenity { background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600; border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px; }
293 +
294 +.prix-histo { margin-top: 14px; padding: 10px 14px; border-radius: var(--r-card); border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface); }
295 +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
296 +
297 +.mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; }
298 +.mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; }
299 +
300 +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(247, 241, 239, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
301 +.cta-sticky .cta { flex: 1; margin: 0; }
302 +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
303 +@media (min-width: 900px) { .cta-sticky { display: none; } }
304 +@media (max-width: 899px) { .detail { padding-bottom: 84px; } }
305 +.f-foot { margin-top: 26px; }
306 +.fine { font-size: 12px; color: var(--ink-3); }
307 +
308 +/* ================= Sources / Agences ================= */
309 +.sources { padding: 44px 0 90px; }
310 +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
311 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 720px; }
312 +.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }
313 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 760px; }
314 +.src-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
315 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
316 +.src-table tr:last-child td { border-bottom: none; }
317 +.src-table tr:hover td { background: var(--surface-2); }
318 +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
319 +.pill.ok { background: var(--lime); color: #fff; border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
320 +.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
321 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); }
322 +
323 +/* ================= Stats ================= */
324 +.stats-page { padding: 44px 0 90px; }
325 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
326 +.stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; }
327 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
328 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); }
329 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; }
330 +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
331 +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
332 +.hero-tile .tile-k { color: rgba(226, 55, 68, 0.7); }
333 +.viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; }
334 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
335 +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
336 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
337 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
338 +.hbars { display: flex; flex-direction: column; gap: 7px; }
339 +.hbar-row { display: grid; grid-template-columns: minmax(96px, 180px) 1fr auto; gap: 12px; align-items: center; min-height: 26px; }
340 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
341 +.hbar-label a { border-bottom: 1.5px solid var(--lime); }
342 +.hbar-label a:hover { color: var(--green-deep); }
343 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
344 +.hbar-fill { display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; min-width: 2px; transition: background 0.12s ease; }
345 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
346 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
347 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
348 +.viz-table { margin-top: 4px; }
349 +.viz-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
350 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
351 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
352 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
353 +
354 +/* ================= Carte ================= */
355 +.map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
356 +.map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
357 +.map-list .card { margin: 0; flex: 0 0 auto; }
358 +.mapview { width: 100%; height: 100%; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; background: var(--surface-2); }
359 +.map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
360 +@media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
361 +.maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); }
362 +.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(26, 18, 20, 0.8); }
363 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
364 +.mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); }
365 +.mv-pop-body { padding: 10px 12px 12px; }
366 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
367 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
368 +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
369 +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
370 +.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: var(--lime); border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
371 +.mv-pop-cta:hover { background: var(--green-deep); }
372 +
373 +/* ================= Menu mobile ================= */
374 +.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
375 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(26, 18, 20,0.15); }
376 +.menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; }
377 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
378 +.menu-btn.open span:nth-child(2) { opacity: 0; }
379 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
380 +.mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(26, 18, 20, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; }
381 +.mobile-menu.open { max-height: 480px; opacity: 1; }
382 +.mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; }
383 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
384 +.mm-link.active { color: var(--green); }
385 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
386 +.mm-arrow { margin-left: auto; opacity: 0.25; }
387 +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
388 +.mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
389 +.mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); }
390 +@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } .mobile-menu { display: block; } }
391 +
392 +/* ================= Footer ================= */
393 +.footer { background: var(--ink); color: rgba(247, 241, 239, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; }
394 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
395 +.footer .fbrand .ka { color: var(--lime); }
396 +.footer b { color: var(--paper); }
397 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 760px; }
398 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(247, 241, 239,0.45); margin-top: 18px; }
399 +
400 +/* ================= Mobile : feuille de filtres + FAB ================= */
401 +.sheet-head { display: none; }
402 +.sheet-apply { display: none; }
403 +.sheet-backdrop { display: none; }
404 +.fab { display: none; }
405 +@media (max-width: 640px) {
406 + .header-inner { height: 56px; }
407 + .brand { font-size: 21px; }
408 + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
409 + .hero p.lede { font-size: 15px; }
410 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
411 + .stat-row::-webkit-scrollbar { display: none; }
412 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
413 + .filterbar { display: none; }
414 + .filterbar.open .f-primary { flex-direction: column; }
415 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
416 + .filterbar.open .f-more { display: none; }
417 + .filterbar.open { display: flex; flex-direction: column; gap: 12px; position: fixed; left: 0; right: 0; bottom: 0; z-index: 95; margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 16px 18px calc(18px + env(safe-area-inset-bottom)); box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35); animation: sheet-up 0.22s ease; }
418 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
419 + .filterbar.open .sheet-head { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-display); font-weight: 700; font-size: 17px; text-transform: uppercase; position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px; border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; }
420 + .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; }
421 + .filterbar.open .sheet-apply { display: block; width: 100%; }
422 + .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); }
423 + .fab { display: flex; align-items: center; gap: 6px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(26, 18, 20, 0.25); }
424 + .fab:active { transform: translateX(-50%) scale(0.97); }
425 + .results-head { margin-top: 20px; }
426 + .chips { margin-right: -16px; padding-right: 16px; }
427 + .notice { padding: 48px 16px; }
428 +}
429 +@media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } }
430 +
431 +/* --- Sources : bannières → sous-agences -------------------------------- */
432 +.franchise-list { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; }
433 +.franchise { border: 2px solid var(--ink); background: var(--surface); box-shadow: var(--shadow-off-soft); }
434 +.franchise-head {
435 + width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
436 + background: none; border: 0; cursor: pointer; font-family: var(--font-display);
437 + font-size: 16px; font-weight: 600; color: var(--ink); text-align: left;
438 +}
439 +.franchise-head:hover { background: var(--surface-2); }
440 +.fh-caret { color: var(--green); width: 14px; }
441 +.fh-name { flex: 1; }
442 +.fh-sub { font-size: 12px; font-weight: 500; color: var(--ink-3); font-family: var(--font-mono); }
443 +.fh-count {
444 + font-family: var(--font-mono); font-weight: 700; background: var(--ink);
445 + color: var(--lime); padding: 3px 10px; min-width: 64px; text-align: right;
446 +}
447 +.subagency-list { list-style: none; margin: 0; padding: 0 16px 10px 40px; border-top: 1px solid var(--line); }
448 +.subagency-list li {
449 + display: flex; align-items: center; justify-content: space-between; gap: 12px;
450 + padding: 8px 0; border-bottom: 1px dotted var(--line); font-size: 14px;
451 +}
452 +.subagency-list li:last-child { border-bottom: 0; }
453 +.sa-name { color: var(--ink-2); }
454 +
455 +/* --- Pagination (accueil) ---------------------------------------------- */
456 +.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
457 + gap: 6px; margin: 32px 0 8px; }
458 +.pager-btn {
459 + font-family: var(--font-mono); font-size: 14px; min-width: 40px; padding: 8px 12px;
460 + border: 2px solid var(--ink); background: var(--surface); color: var(--ink);
461 + cursor: pointer; box-shadow: var(--shadow-off-soft);
462 +}
463 +.pager-btn:hover:not(:disabled) { background: var(--lime-soft); }
464 +.pager-btn.on { background: var(--ink); color: var(--lime); font-weight: 700; }
465 +.pager-btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; }
466 +.pager-gap { padding: 0 4px; color: var(--ink-3); }
467 +.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px;
468 + color: var(--ink-3); font-family: var(--font-mono); }
469 +
470 +
471 +
472 +/* --- Le quartier (census / proximité / chaleur / crime) — porté de Lou-Ka --- */
473 +/* --- Section « Le quartier » (fiche) --------------------------------------- */
474 +.quartier { margin-top: 34px; }
475 +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; }
476 +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
477 +.q-grid {
478 + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
479 +}
480 +.q-grid { grid-template-columns: repeat(2, 1fr); }
481 +.q-cell {
482 + background: var(--surface); border: 1.5px solid var(--line-strong);
483 + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat);
484 +}
485 +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
486 +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
487 + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; }
488 +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
489 +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
490 +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); }
491 +.q-bar-label { flex-basis: 120px; font-size: 12px; }
492 +.q-bar-track {
493 + flex: 1; height: 10px; background: var(--surface-2);
494 + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
495 +}
496 +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; }
497 +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono);
498 + font-size: 11.5px; font-weight: 700; }
499 +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
500 +.q-badge {
501 + display: inline-flex; align-items: center; gap: 6px;
502 + border: 1.5px solid var(--line-strong); border-radius: 999px;
503 + padding: 7px 13px; font-size: 12.5px; background: var(--surface);
504 +}
505 +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
506 +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
507 +.quartier .fine { margin-top: 10px; }
508 +.q-cell { padding: 9px 11px; }
509 +.q-val { font-size: 16px; }
510 +.q-bar { gap: 8px; font-size: 12px; }
511 +.q-bar-track { height: 8px; }
512 +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; }
513 +
514 +/* --- Vrai-Prix : estimation de valeur ---------------------------------- */
515 +.vraiprix { border: 2px solid var(--ink); background: var(--surface-2);
516 + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 14px 0; }
517 +.vp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
518 +.vp-logo { font-family: var(--font-display); font-weight: 700; color: var(--green-deep);
519 + letter-spacing: -.3px; }
520 +.vp-conf { font-family: var(--font-mono); font-size: 11px; padding: 2px 7px; border: 1px solid var(--ink); }
521 +.vp-conf-A { background: var(--lime); color: #fff; } .vp-conf-B { background: var(--lime-soft); }
522 +.vp-conf-C { background: var(--amber-soft); } .vp-conf-D { background: #f0d9d9; }
523 +.vp-k { font-size: 11px; color: var(--ink-3); text-transform: uppercase;
524 + letter-spacing: .5px; margin-top: 8px; }
525 +.vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700;
526 + letter-spacing: -1px; color: var(--ink); }
527 +.vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); }
528 +.vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); }
529 +.vp-over { background: #f6e2e2; color: #8a2b2b; }
530 +.vp-under { background: #e2f0e6; color: #1c5c41; }
531 +.vp-fair { background: var(--surface); color: var(--ink-2); }
532 +.vp-link { display: block; text-align: center; margin-top: 10px; font-weight: 700;
533 + font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff;
534 + text-decoration: none; }
535 +.vp-link:hover { background: var(--green); }
modified frontend/tsconfig.json +3 −18
@@ -2,11 +2,7 @@
2 2 "compilerOptions": {
3 3 "target": "ES2020",
4 4 "useDefineForClassFields": true,
5 − "lib": [
6 − "ES2020",
7 − "DOM",
8 − "DOM.Iterable"
9 − ],
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
10 6 "module": "ESNext",
11 7 "skipLibCheck": true,
12 8 "moduleResolution": "bundler",
@@ -18,18 +14,7 @@
18 14 "strict": true,
19 15 "noUnusedLocals": false,
20 16 "noUnusedParameters": false,
21 − "noFallthroughCasesInSwitch": true,
22 − "baseUrl": ".",
23 − "paths": {
24 − "react": [
25 − "./node_modules/@types/react"
26 − ],
27 − "react-dom": [
28 − "./node_modules/@types/react-dom"
29 − ]
30 − }
17 + "noFallthroughCasesInSwitch": true
31 18 },
32 − "include": [
33 − "src"
34 − ]
19 + "include": ["src"]
35 20 }
modified frontend/vite.config.ts +0 −5
@@ -8,11 +8,6 @@ import react from "@vitejs/plugin-react";
8 8
9 9 export default defineConfig({
10 10 plugins: [react()],
11 − resolve: {
12 − // @groupe-ka/ka-maps est lié en local (file:../../ka-maps) : forcer une
13 − // seule copie de React et de Mapbox GL (celle de l'app).
14 − dedupe: ["react", "react-dom", "mapbox-gl"],
15 − },
16 11 server: {
17 12 proxy: { "/api": "http://localhost:8090" },
18 13 },
added geocode.py +378 −0
@@ -0,0 +1,378 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (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 sqlite3
15 +
16 +import re
17 +import time
18 +
19 +import requests
20 +
21 +from . import db
22 +from .normalize import strip_accents
23 +
24 +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
25 +# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre
26 +# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…)
27 +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
28 + "Adresse_Geocodage/GeocodeServer/findAddressCandidates")
29 +AQ_MIN_SCORE = 75
30 +# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête
31 +# User-Agent contient des accents (latin-1)
32 +USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"
33 +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s
34 +REQUEST_DELAY_AQ = 0.12 # Adresses Québec (MERN) : pas de limite stricte
35 +RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours
36 +
37 +# Bounding boxes (lat_min, lat_max, lng_min, lng_max)
38 +_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin
39 +# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord
40 +# et la Gaspésie (expansion provinciale) — même boîte que schema.finalize()
41 +_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)
42 +
43 +_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",
44 + "l'ancienne-lorette", "ancienne-lorette"}
45 +
46 +
47 +def _bbox_for(city: str) -> tuple[float, float, float, float]:
48 + key = strip_accents((city or "").strip().lower())
49 + return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC
50 +
51 +
52 +def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:
53 + return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]
54 +
55 +
56 +def norm_key(address: str) -> str:
57 + """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""
58 + s = strip_accents((address or "").lower())
59 + s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)
60 + s = re.sub(r"[^a-z0-9]+", " ", s)
61 + return re.sub(r"\s+", " ", s).strip()
62 +
63 +
64 +class Geocoder:
65 + def __init__(self, con) -> None:
66 + self.con = con
67 + self.session = requests.Session()
68 + self.session.headers["User-Agent"] = USER_AGENT
69 + self._last = 0.0
70 +
71 + @staticmethod
72 + def _clean(address: str) -> str:
73 + """Nettoyages qui aident Nominatim sur les adresses québécoises."""
74 + s = address.strip()
75 + # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique
76 + s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)
77 + # « 6275 et 6375 boulevard X » -> premier civique
78 + s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s)
79 + # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu
80 + s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)
81 + # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau
82 + s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I)
83 + # « Vanier (Québec) » -> « Vanier, Québec »
84 + s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I)
85 + # abréviations cardinales : « Rue Salaberry O » -> « Ouest »
86 + s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)
87 + s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)
88 + return s
89 +
90 + def _build_query(self, address: str, city: str) -> str:
91 + q = self._clean(address)
92 + key = strip_accents(q.lower())
93 + if city and strip_accents(city.lower()) not in key:
94 + q += f", {city}"
95 + if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
96 + q += ", Québec"
97 + return q + ", Canada"
98 +
99 + def _query_nominatim(self, params: dict) -> tuple[float, float] | None:
100 + """Une requête Nominatim, throttlée."""
101 + wait = REQUEST_DELAY - (time.time() - self._last)
102 + if wait > 0:
103 + time.sleep(wait)
104 + try:
105 + resp = self.session.get(NOMINATIM_URL, params={
106 + "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params,
107 + }, timeout=20)
108 + self._last = time.time()
109 + resp.raise_for_status()
110 + hits = resp.json()
111 + except Exception:
112 + self._last = time.time()
113 + return None
114 + if not hits:
115 + return None
116 + try:
117 + return float(hits[0]["lat"]), float(hits[0]["lon"])
118 + except (KeyError, ValueError):
119 + return None
120 +
121 + def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:
122 + """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).
123 +
124 + Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE
125 + pour éviter les correspondances approximatives sur une autre rue.
126 + """
127 + premier = self._clean(address).split(",")[0].strip()
128 + ville = (city or "Québec").strip()
129 + wait = REQUEST_DELAY_AQ - (time.time() - self._last)
130 + if wait > 0:
131 + time.sleep(wait)
132 + try:
133 + resp = self.session.get(AQ_URL, params={
134 + "SingleLine": f"{premier}, {ville}",
135 + "f": "json", "outSR": 4326, "maxLocations": 1,
136 + }, timeout=20)
137 + self._last = time.time()
138 + resp.raise_for_status()
139 + cands = resp.json().get("candidates") or []
140 + except Exception:
141 + self._last = time.time()
142 + return None
143 + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:
144 + return None
145 + loc = cands[0].get("location") or {}
146 + try:
147 + return float(loc["y"]), float(loc["x"])
148 + except (KeyError, ValueError):
149 + return None
150 +
151 + def _attempts(self, address: str, city: str) -> list[dict]:
152 + """Stratégies de requête, de la plus précise à la moins précise.
153 +
154 + 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)
155 + 2) recherche libre complète
156 + 3) structurée rue seule -> centroïde de rue (repli acceptable pour la
157 + carte quand le numéro civique est absent d'OpenStreetMap)
158 + """
159 + premier = self._clean(address).split(",")[0].strip()
160 + ville = (city or "").strip()
161 + if not ville:
162 + k = strip_accents(address.lower())
163 + ville = "Lévis" if "levis" in k else "Québec"
164 + commun = {"city": ville, "state": "Québec", "country": "Canada"}
165 +
166 + tries: list[dict] = []
167 + m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)
168 + if m:
169 + tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})
170 + tries.append({"q": self._build_query(address, city)})
171 + if m:
172 + tries.append({"street": m.group(2), **commun})
173 + return tries
174 +
175 + def resolve(self, address: str, city: str) -> tuple[float, float] | None:
176 + """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable."""
177 + key = norm_key(address)
178 + if not key or len(key) < 6:
179 + return None
180 + row = self.con.execute(
181 + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",
182 + (key,)).fetchone()
183 + if row is not None:
184 + if not row["failed"]:
185 + return (row["lat"], row["lng"])
186 + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
187 + return None # échec récent : ne pas marteler l'API
188 +
189 + bbox = _bbox_for(city)
190 + coords = None
191 + # Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de
192 + # limite stricte. Nominatim (1 req/s) seulement en repli.
193 + provider = "adresses_quebec"
194 + c = self._query_adresses_quebec(address, city)
195 + if c is not None and _in_bbox(*c, bbox):
196 + coords = c
197 + if coords is None:
198 + provider = "nominatim"
199 + for params in self._attempts(address, city):
200 + c = self._query_nominatim(params)
201 + if c is not None and _in_bbox(*c, bbox):
202 + coords = c
203 + break
204 + ok = coords is not None
205 + self.con.execute(
206 + "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"
207 + " VALUES (?,?,?,?,?,?)"
208 + " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"
209 + " lng=excluded.lng, provider=excluded.provider,"
210 + " failed=excluded.failed, ts=excluded.ts",
211 + (key, coords[0] if ok else None, coords[1] if ok else None,
212 + provider, 0 if ok else 1, time.time()))
213 + self.con.commit()
214 + return coords if ok else None
215 +
216 +
217 +def run(limit: int | None = None) -> dict:
218 + """Géocode les annonces actives sans coordonnées, par adresse unique.
219 +
220 + `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de
221 + cache sont gratuits et toujours appliqués).
222 + """
223 + con = db.connect()
224 + geo = Geocoder(con)
225 + rows = con.execute(
226 + """SELECT uid, address, city FROM listings
227 + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
228 + AND dup_hidden=0
229 + ORDER BY address""").fetchall()
230 +
231 + # regrouper par immeuble (adresse normalisée)
232 + groupes: dict[str, list] = {}
233 + for r in rows:
234 + groupes.setdefault(norm_key(r["address"]), []).append(r)
235 +
236 + done = failed = requests_made = 0
237 + for key, members in groupes.items():
238 + if not key:
239 + continue
240 + cached = con.execute(
241 + "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()
242 + if cached is None:
243 + if limit is not None and requests_made >= limit:
244 + continue
245 + requests_made += 1
246 + try:
247 + coords = geo.resolve(members[0]["address"], members[0]["city"])
248 + if coords:
249 + for r in members:
250 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
251 + (coords[0], coords[1], r["uid"]))
252 + done += len(members)
253 + else:
254 + # introuvable/hors zone : flag ; jamais de coordonnées bidon
255 + for r in members:
256 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
257 + (r["uid"],))
258 + failed += len(members)
259 + con.commit()
260 + except sqlite3.OperationalError:
261 + # verrou transitoire (watcher/refresh_dedup) : on saute cette adresse,
262 + # elle sera reprise au prochain passage — jamais crasher tout le run.
263 + try:
264 + con.rollback()
265 + except sqlite3.Error:
266 + pass
267 + time.sleep(1.0)
268 + continue
269 +
270 + con.close()
271 + stats = {"geocoded": done, "failed": failed,
272 + "unique_addresses": len(groupes), "api_requests": requests_made}
273 + print(f"[immo-ka] geocode {stats}")
274 + return stats
275 +
276 +
277 +AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
278 + "Adresse_Geocodage/GeocodeServer/geocodeAddresses")
279 +BATCH_SIZE = 200
280 +
281 +
282 +def run_batch(limit: int | None = None) -> dict:
283 + """Géocodage EN LOT via Adresses Québec (`geocodeAddresses`, jusqu'à 1000
284 + adresses/requête) — ~35 requêtes pour tout le parc au lieu de dizaines de
285 + milliers. Beaucoup plus rapide que le mode 1-par-1 (et que Nominatim)."""
286 + con = db.connect()
287 + geo = Geocoder(con) # pour réutiliser _clean / bbox
288 + rows = con.execute(
289 + """SELECT uid, address, city FROM listings
290 + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
291 + AND dup_hidden=0 ORDER BY address""").fetchall()
292 + # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache
293 + uniq: dict[str, dict] = {}
294 + for r in rows:
295 + k = norm_key(r["address"])
296 + if not k or len(k) < 6:
297 + continue
298 + u = uniq.setdefault(k, {"address": r["address"], "city": r["city"],
299 + "members": []})
300 + u["members"].append(r["uid"])
301 + pending = []
302 + for k, u in uniq.items():
303 + c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?",
304 + (k,)).fetchone()
305 + if c is not None and not c["failed"]: # déjà résolu : appliquer direct
306 + for uid in u["members"]:
307 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
308 + (c["lat"], c["lng"], uid))
309 + continue
310 + if c is not None and c["failed"]:
311 + continue
312 + pending.append((k, u))
313 + con.commit()
314 + if limit is not None:
315 + pending = pending[:limit]
316 +
317 + session = requests.Session()
318 + session.headers["User-Agent"] = USER_AGENT
319 + done = failed = 0
320 + for i in range(0, len(pending), BATCH_SIZE):
321 + chunk = pending[i:i + BATCH_SIZE]
322 + records = {"records": [
323 + {"attributes": {"OBJECTID": j,
324 + "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, "
325 + f"{(u['city'] or 'Québec').strip()}"}}
326 + for j, (_k, u) in enumerate(chunk)]}
327 + try:
328 + resp = session.get(AQ_BATCH_URL, params={
329 + "addresses": json.dumps(records, ensure_ascii=False),
330 + "f": "json", "outSR": 4326}, timeout=60)
331 + locs = resp.json().get("locations", [])
332 + except Exception:
333 + time.sleep(1.0)
334 + continue
335 + by_id = {l["attributes"].get("ResultID"): l for l in locs}
336 + for j, (k, u) in enumerate(chunk):
337 + loc = by_id.get(j)
338 + coords = None
339 + if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE:
340 + lc = loc.get("location") or {}
341 + try:
342 + cand = (float(lc["y"]), float(lc["x"]))
343 + if _in_bbox(*cand, _bbox_for(u["city"])):
344 + coords = cand
345 + except (KeyError, ValueError, TypeError):
346 + coords = None
347 + try:
348 + if coords:
349 + for uid in u["members"]:
350 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
351 + (coords[0], coords[1], uid))
352 + con.execute(
353 + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"
354 + " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET"
355 + " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider,"
356 + " failed=0, ts=excluded.ts",
357 + (k, coords[0], coords[1], "adresses_quebec_batch", time.time()))
358 + done += len(u["members"])
359 + else:
360 + for uid in u["members"]:
361 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (uid,))
362 + con.execute(
363 + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"
364 + " VALUES (?,?,?,?,1,?) ON CONFLICT(address) DO UPDATE SET"
365 + " failed=1, ts=excluded.ts",
366 + (k, None, None, "adresses_quebec_batch", time.time()))
367 + failed += len(u["members"])
368 + con.commit()
369 + except sqlite3.OperationalError:
370 + try: con.rollback()
371 + except sqlite3.Error: pass
372 + time.sleep(1.0)
373 + print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} "
374 + f"(résolues {done}, échecs {failed})")
375 + con.close()
376 + stats = {"geocoded": done, "failed": failed, "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE}
377 + print(f"[immo-ka] geocode-batch {stats}")
378 + return stats
added vraiprix_local.py +173 −0
@@ -0,0 +1,173 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# vraiprix_local.py : appariement LOCAL contre la base Vrai-Prix (vraiprix.db,
5 +# 3,7 M unités d'évaluation avec adresse, lat/lng, estimation, fourchette).
6 +# Une jointure d'adresse (FTS) par annonce → remplit d'un coup :
7 +# · lat/lng manquants (géocodage instantané, sans API externe)
8 +# · l'estimation Vrai-Prix (valeur + P10-P90 + lien /estimation/{id})
9 +# Bien plus rapide que l'API vrai-prix (une requête réseau par annonce).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import os
15 +import re
16 +import sqlite3
17 +import time
18 +import unicodedata
19 +
20 +from . import db
21 +
22 +# Emplacement de la base Vrai-Prix (copiée à côté de immoka.db sur le nœud).
23 +VP_DB = os.environ.get(
24 + "VRAIPRIX_DB",
25 + str((__import__("pathlib").Path(__file__).resolve().parent.parent
26 + / "data" / "vraiprix.db")))
27 +SITE = "https://www.vrai-prix.com"
28 +_BBOX = (44.5, 63.0, -80.0, -56.0) # Québec
29 +
30 +
31 +def _norm(s: str) -> str:
32 + return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())
33 + if unicodedata.category(c) != "Mn").strip()
34 +
35 +
36 +# mots de « type de voie » ignorés dans l'appariement (variabilité rue/av/boul…)
37 +_VOIE = {"rue", "av", "ave", "avenue", "boul", "boulevard", "ch", "chemin",
38 + "place", "pl", "rang", "montee", "montée", "cote", "côte", "route",
39 + "rte", "terrasse", "impasse", "croissant", "cours", "allee", "allée",
40 + "de", "du", "des", "la", "le", "les", "l", "d", "st", "ste", "saint",
41 + "sainte"}
42 +
43 +
44 +def _street_words(norm_addr: str) -> set:
45 + """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.)."""
46 + a = re.sub(r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+", " ",
47 + norm_addr.split(",")[0])
48 + a = re.sub(r"[^a-z0-9 ]+", " ", a)
49 + toks = [t for t in a.split() if t]
50 + return {t for t in toks if not t.isdigit() and t not in _VOIE and len(t) > 1}
51 +
52 +
53 +def _fts_query(address: str) -> tuple[str, str]:
54 + """Construit une requête FTS AND (n° civique + mots significatifs de la rue,
55 + sans le type de voie ni le n° d'appartement) + retourne le civique."""
56 + a = _norm(address).split(",")[0]
57 + a = re.sub(r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+", " ", a)
58 + a = re.sub(r"[^a-z0-9 ]+", " ", a)
59 + toks = [t for t in a.split() if t]
60 + civ = toks[0] if toks and toks[0].isdigit() else ""
61 + words = [t for t in toks if t != civ and t not in _VOIE and len(t) > 1]
62 + parts = ([civ] if civ else []) + words
63 + if not parts:
64 + return "", civ
65 + return " AND ".join(f'"{p}"' for p in parts), civ
66 +
67 +
68 +def available() -> bool:
69 + return os.path.exists(VP_DB)
70 +
71 +
72 +def _match(vp: sqlite3.Connection, address: str, city: str) -> dict | None:
73 + """Meilleure unité Vrai-Prix pour (adresse, ville)."""
74 + # n° civique + mots significatifs de la rue (AND), filtre municipalité
75 + q, civ = _fts_query(address)
76 + if not q:
77 + return None
78 + try:
79 + rows = vp.execute(
80 + "SELECT u.id_provinc, u.adresse, u.municipalite, u.lat, u.lng,"
81 + " u.est_hedo, u.est_2026, u.p10, u.p90"
82 + " FROM units_fts f JOIN units u ON u.rowid=f.rowid"
83 + " WHERE units_fts MATCH ? LIMIT 25", (q,)).fetchall()
84 + except sqlite3.OperationalError:
85 + return None
86 + if not rows:
87 + return None
88 + # VALIDATION STRICTE : on n'accepte QUE si même adresse — n° civique
89 + # identique + au moins un mot de rue en commun + même municipalité. Sinon
90 + # PAS de match (mieux vaut aucune estimation qu'une mauvaise).
91 + iwords = _street_words(_norm(address))
92 + nc = _norm(city).split()[0] if city else ""
93 +
94 + def _accept(r) -> bool:
95 + ua = _norm(r["adresse"])
96 + m = re.match(r"\s*(\d+)", ua)
97 + if not civ or not m or m.group(1) != civ: # même n° civique
98 + return False
99 + uwords = _street_words(ua)
100 + if iwords and not (iwords & uwords): # mot de rue commun
101 + return False
102 + um = _norm(r["municipalite"] or "")
103 + if nc and um and nc not in um and um.split()[0] not in _norm(city):
104 + return False # même municipalité
105 + return True
106 +
107 + best = next((r for r in rows if _accept(r)), None)
108 + if best is None:
109 + return None
110 + lat, lng = best["lat"], best["lng"]
111 + val = best["est_hedo"] or best["est_2026"]
112 + return {
113 + "id": best["id_provinc"], "lat": lat, "lng": lng,
114 + "value": val, "low": best["p10"], "high": best["p90"],
115 + "confidence": None, "confidence_pct": None,
116 + "url": f"{SITE}/estimation/{best['id_provinc']}",
117 + }
118 +
119 +
120 +def run(limit: int | None = None, revalidate: bool = False) -> dict:
121 + """Apparie les annonces à la base Vrai-Prix : remplit lat/lng (si manquant)
122 + et l'estimation. `limit` borne le nombre d'annonces traitées.
123 + `revalidate=True` : re-vérifie AUSSI les annonces déjà appariées (corrige les
124 + mauvais merges après durcissement du matcher)."""
125 + if not available():
126 + print(f"[immo-ka] vraiprix_local: base absente ({VP_DB})")
127 + return {"error": "vraiprix.db absent"}
128 + vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True)
129 + vp.row_factory = sqlite3.Row
130 + con = db.connect()
131 + where = ("active=1 AND dup_hidden=0 AND address<>''" if revalidate
132 + else "active=1 AND dup_hidden=0 AND address<>''"
133 + " AND (vraiprix IS NULL OR vraiprix='{}')")
134 + rows = con.execute(
135 + f"SELECT uid, address, city, lat FROM listings WHERE {where}"
136 + " ORDER BY first_seen DESC").fetchall()
137 + if limit is not None:
138 + rows = rows[:limit]
139 +
140 + matched = geoloc = miss = 0
141 + for i, r in enumerate(rows):
142 + m = _match(vp, r["address"], r["city"])
143 + try:
144 + if m and m["value"]:
145 + est = {k: m[k] for k in ("id", "value", "low", "high",
146 + "confidence", "confidence_pct", "url")}
147 + con.execute("UPDATE listings SET vraiprix=? WHERE uid=?",
148 + (json.dumps(est, ensure_ascii=False), r["uid"]))
149 + # géocodage gratuit : coordonnées de l'unité si l'annonce n'en a pas
150 + if (r["lat"] is None and m["lat"] is not None
151 + and _BBOX[0] <= m["lat"] <= _BBOX[1]
152 + and _BBOX[2] <= m["lng"] <= _BBOX[3]):
153 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
154 + (m["lat"], m["lng"], r["uid"]))
155 + geoloc += 1
156 + matched += 1
157 + else:
158 + con.execute("UPDATE listings SET vraiprix='{}' WHERE uid=?", (r["uid"],))
159 + miss += 1
160 + if i % 200 == 0:
161 + con.commit()
162 + except sqlite3.OperationalError:
163 + try:
164 + con.rollback()
165 + except sqlite3.Error:
166 + pass
167 + time.sleep(1.0)
168 + con.commit()
169 + con.close()
170 + vp.close()
171 + out = {"matched": matched, "geolocated": geoloc, "no_match": miss}
172 + print(f"[immo-ka] vraiprix_local {out}")
173 + return out
174