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