SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
10.7 KB · 188 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/NearbyPlaces.tsx : « À proximité » et « Transport » — carrousels de5//   cartes horizontales (transport · courses · services) construits à partir6//   des POI OpenStreetMap (louka/poi.py) et des grandes bannières (Mapbox),7//   les 4 lieux les plus pertinents en liste, puis « Voir les N lieux »8//   → bottom sheet groupé par catégorie.9// -----------------------------------------------------------------------------10import { ReactNode, useState } from "react";11import { CommerceItem, CommercesNearby, Poi, fmtDist } from "../api";12import {13  IcoBaby, IcoBook, IcoBus, IcoCart, IcoCoffee, IcoDumbbell, IcoHospital, IcoMapPin, IcoPill,14  IcoSchool, IcoStore, IcoTrain, IcoTrees,15} from "../components/Icons";16import BottomSheet from "./BottomSheet";17import { ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, fmtMarche, NBSP } from "./ui";18import { Res } from "./useFicheData";1920const POI_META: Record<string, { ico: ReactNode; label: string }> = {21  epicerie: { ico: <IcoCart size={17} />, label: "Épicerie" },22  depanneur: { ico: <IcoStore size={17} />, label: "Dépanneur" },23  pharmacie: { ico: <IcoPill size={17} />, label: "Pharmacie" },24  ecole: { ico: <IcoSchool size={17} />, label: "École" },25  garderie: { ico: <IcoBaby size={17} />, label: "Garderie" },26  parc: { ico: <IcoTrees size={17} />, label: "Parc" },27  bus: { ico: <IcoBus size={17} />, label: "Arrêt de bus" },28  metro: { ico: <IcoTrain size={17} />, label: "Métro" },29  gym: { ico: <IcoDumbbell size={17} />, label: "Gym" },30  cafe: { ico: <IcoCoffee size={17} />, label: "Café" },31  clinique: { ico: <IcoHospital size={17} />, label: "Clinique" },32  hopital: { ico: <IcoHospital size={17} />, label: "Hôpital" },33  bibliotheque: { ico: <IcoBook size={17} />, label: "Bibliothèque" },34};35const GROUPES: { titre: string; cats: string[] }[] = [36  { titre: "Transport", cats: ["metro", "bus"] },37  { titre: "Courses", cats: ["epicerie", "depanneur", "pharmacie"] },38  { titre: "Études et famille", cats: ["ecole", "garderie", "bibliotheque"] },39  { titre: "Santé", cats: ["clinique", "hopital"] },40  { titre: "Vie de quartier", cats: ["cafe", "parc", "gym"] },41];42// bannières : [couleur, monogramme, texte]43const BANNIERE_CAT: Record<string, string> = {44  metro_station: "Station de métro", rem_station: "Station REM", arret_bus: "Arrêt de bus", gare_train: "Gare de train",45  costco: "Épicerie · entrepôt", walmart: "Grande surface", metro: "Épicerie", iga: "Épicerie", maxi: "Épicerie",46  superc: "Épicerie", provigo: "Épicerie", canadiantire: "Quincaillerie", dollarama: "Magasin à 1 $", saq: "Alcools",47  pharmaprix: "Pharmacie", jeancoutu: "Pharmacie", homedepot: "Rénovation", rona: "Rénovation",48};49const catDe = (c: CommerceItem) => BANNIERE_CAT[c.id] ?? c.commerce;50const BANNIERES: Record<string, [string, string, string?]> = {51  metro_station: ["#0083C9", "M"], rem_station: ["#84BD00", "R"], arret_bus: ["#4E5357", "B"], gare_train: ["#6E5B3F", "T"],52  costco: ["#005DAA", "C"], walmart: ["#0071CE", "W"], metro: ["#EF3E42", "M"], iga: ["#D50032", "IGA"],53  maxi: ["#0079C1", "Mx"], superc: ["#E4002B", "SC"], provigo: ["#DA291C", "P"], canadiantire: ["#D6001C", "CT"],54  dollarama: ["#00B140", "D", "#FFDD00"], saq: ["#892034", "SAQ"], pharmaprix: ["#E11B22", "Ph"],55  jeancoutu: ["#003DA5", "JC"], homedepot: ["#F96302", "HD"], rona: ["#1B4298", "R"],56};5758export function Pastille({ id }: { id: string }) {59  const [bg, mono, fg] = BANNIERES[id] ?? ["#777", "•"];60  const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;61  const rond = ["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id);62  return (63    <svg className="cm-ico" viewBox="0 0 28 28" width="26" height="26" aria-hidden="true">64      {rond ? <circle cx="14" cy="14" r="13" fill={bg} /> : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}65      <text x="14" y="14" textAnchor="middle" dominantBaseline="central" fontSize={fs} fontWeight="800" fontFamily="inherit" fill={fg ?? "#fff"}>{mono}</text>66    </svg>67  );68}6970interface Carte { key: string; cat: string; nom: string; sous?: string; dist: number; ico: ReactNode; }7172function CCard({ c }: { c: Carte }) {73  return (74    <div className="lk-ccard" role="listitem">75      <div className="lk-ccard-top">76        <span className="lk-ccard-c">{c.cat}</span>77        <span aria-hidden="true">{c.ico}</span>78      </div>79      <div className="lk-ccard-d">{fmtDist(c.dist)}</div>80      <div className="lk-ccard-n" title={c.nom}>{c.nom}</div>81      <div className="lk-ccard-m">≈{NBSP}{fmtMarche(c.dist)} à pied{c.sous ? ` · ${c.sous}` : ""}</div>82    </div>83  );84}8586export default function NearbyPlaces({ pois, commerces, onRetry }: { pois: Poi[]; commerces: Res<CommercesNearby>; onRetry: () => void }) {87  const [sheet, setSheet] = useState(false);88  const cm = commerces.status === "ok" ? commerces.data : null;89  const transit: CommerceItem[] = cm?.transit ?? [];90  const bannieres: CommerceItem[] = cm?.commerces ?? [];9192  // cartes « Transport » : métro/bus/REM/train (bannières transit) + POI bus/métro93  const transport: Carte[] = [94    ...transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })),95    ...pois.filter((p) => (p.cat === "metro" || p.cat === "bus") && !transit.some((t) => t.dist_m === p.dist_m))96      .map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat].label, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat].ico })),97  ].sort((a, b) => a.dist - b.dist);98  // cartes « Courses et services » : bannières + POI hors transport99  const services: Carte[] = [100    ...bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })),101    ...pois.filter((p) => p.cat !== "metro" && p.cat !== "bus")102      .map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat]?.label ?? p.cat, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat]?.ico ?? <IcoMapPin size={17} /> })),103  ].sort((a, b) => a.dist - b.dist);104  const total = transport.length + services.length;105  const loading = commerces.status === "loading" || commerces.status === "idle";106107  if (total === 0 && !loading && commerces.status !== "error") return null;108109  // « les plus pertinents » : métro/épicerie/pharmacie/parc/école les plus proches110  const prio = ["Station de métro", "Métro", "Épicerie", "Pharmacie", "Parc", "École", "Arrêt de bus"];111  const top = [...transport, ...services]112    .sort((a, b) => (prio.findIndex((p) => a.cat.startsWith(p)) + 1 || 99) - (prio.findIndex((p) => b.cat.startsWith(p)) + 1 || 99) || a.dist - b.dist)113    .filter((c, i, arr) => arr.findIndex((x) => x.cat === c.cat) === i)114    .slice(0, 4);115116  return (117    <>118      <SectionCard id="proximite" title="À proximité" icon={<IcoMapPin size={18} />}119                   sub={total ? `${total} lieux repérés · temps de marche estimés` : undefined}>120        {loading && total === 0 && <SkeletonLines n={3} />}121        {commerces.status === "error" && <ErrorState onRetry={onRetry}>Commerces et transport temporairement indisponibles.</ErrorState>}122        {top.length > 0 && (123          <ul className="lk-list">124            {top.map((c) => (125              <li className="lk-item" key={c.key}>126                <span className="lk-item-ico" aria-hidden="true">{c.ico}</span>127                <div className="lk-item-main">128                  <div className="lk-item-t">{c.nom}</div>129                  <div className="lk-item-s">{c.cat}</div>130                </div>131                <div className="lk-item-r">132                  <div className="lk-item-v">{fmtDist(c.dist)}</div>133                  <div className="lk-item-m">≈{NBSP}{fmtMarche(c.dist)}</div>134                </div>135              </li>136            ))}137          </ul>138        )}139        {services.length > 0 && (140          <>141            <h3 className="lk-card-sub" style={{ margin: "14px 0 8px", fontWeight: 600, color: "var(--lk-text-2)" }}>Courses et services</h3>142            <div className="lk-carousel" role="list" aria-label="Courses et services à proximité">143              {services.slice(0, 12).map((c) => <CCard c={c} key={c.key} />)}144            </div>145          </>146        )}147        {total > 4 && <MoreButton onClick={() => setSheet(true)}>Voir les {total} lieux à proximité</MoreButton>}148        <SourceLine name="OpenStreetMap · Mapbox Search"149                    date={`distances à vol d'oiseau, marche ≈ distance × 1,3 à 4,8${NBSP}km/h`} />150      </SectionCard>151152      {transport.length > 0 && (153        <SectionCard id="transport" title="Transport" icon={<IcoTrain size={18} />}154                     sub="Stations et arrêts les plus proches">155          <div className="lk-carousel" role="list" aria-label="Transport en commun à proximité">156            {transport.slice(0, 10).map((c) => <CCard c={c} key={c.key} />)}157          </div>158        </SectionCard>159      )}160161      <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Lieux à proximité" sub={`${total} lieux · distances à vol d'oiseau`} tall>162        {GROUPES.map((g) => {163          const items = [164            ...pois.filter((p) => g.cats.includes(p.cat)).map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat]?.label ?? p.cat, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat]?.ico })),165            ...(g.titre === "Transport" ? transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })) : []),166            ...(g.titre === "Courses" ? bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })) : []),167          ].sort((a, b) => a.dist - b.dist);168          if (items.length === 0) return null;169          return (170            <div key={g.titre} style={{ marginBottom: 14 }}>171              <h4 className="lk-card-sub" style={{ fontWeight: 700, color: "var(--lk-text)", margin: "0 0 4px" }}>{g.titre} <small style={{ fontWeight: 500 }}>· {items.length}</small></h4>172              <ul className="lk-list">173                {items.map((c) => (174                  <li className="lk-item" key={c.key + c.dist}>175                    <span className="lk-item-ico" aria-hidden="true">{c.ico}</span>176                    <div className="lk-item-main"><div className="lk-item-t">{c.nom}</div><div className="lk-item-s">{c.cat}</div></div>177                    <div className="lk-item-r"><div className="lk-item-v">{fmtDist(c.dist)}</div><div className="lk-item-m">≈{NBSP}{fmtMarche(c.dist)}</div></div>178                  </li>179                ))}180              </ul>181            </div>182          );183        })}184      </BottomSheet>185    </>186  );187}188