Skip to content

Commit 2f490df

Browse files
authored
Merge pull request #37 from T-DAT-902-Homepedia/feat/undervalued-table
feat: sortable undervalued communes table (#34)
2 parents 582b883 + 90b4cc2 commit 2f490df

4 files changed

Lines changed: 285 additions & 9 deletions

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"
33
import Landing from "@/pages/landing"
44
import DvfMap from "@/pages/dvf-map"
55
import ScoreMap from "@/pages/score-map"
6+
import Undervalued from "@/pages/undervalued"
67

78
export function App() {
89
return (
@@ -11,6 +12,7 @@ export function App() {
1112
<Route path="/" element={<Landing />} />
1213
<Route path="/carte" element={<DvfMap />} />
1314
<Route path="/map" element={<ScoreMap />} />
15+
<Route path="/classement" element={<Undervalued />} />
1416
</Routes>
1517
</BrowserRouter>
1618
)

src/pages/landing.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export function Landing() {
4646
<Button variant="ghost" size="sm" asChild>
4747
<a href="#fonctionnalites">Fonctionnalités</a>
4848
</Button>
49+
<Button variant="ghost" size="sm" asChild>
50+
<Link to="/classement">Communes sous-cotées</Link>
51+
</Button>
4952
<Button
5053
size="sm"
5154
className="bg-accent text-accent-foreground hover:bg-accent/90"

src/pages/score-map.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ import { loadWordCloud, type CityWordCloud } from "@/lib/parseAvis"
3131
import WordCloudPopup from "@/components/WordCloudPopup"
3232
import { CommunePanel } from "@/components/commune-panel"
3333

34-
// Centre (bbox) d'une géométrie GeoJSON, pour recentrer sur une commune au
35-
// deep-link. Parcourt récursivement les coordonnées (Polygon / MultiPolygon).
36-
function geometryCenter(geom: ScoreFeature["geometry"]): [number, number] {
34+
// Bbox d'une géométrie GeoJSON, pour cadrer une commune au deep-link.
35+
// Parcourt récursivement les coordonnées (Polygon / MultiPolygon).
36+
function geometryBounds(
37+
geom: ScoreFeature["geometry"],
38+
): [[number, number], [number, number]] {
3739
let minX = Infinity,
3840
minY = Infinity,
3941
maxX = -Infinity,
@@ -50,7 +52,10 @@ function geometryCenter(geom: ScoreFeature["geometry"]): [number, number] {
5052
}
5153
}
5254
visit((geom as { coordinates: unknown }).coordinates)
53-
return [(minX + maxX) / 2, (minY + maxY) / 2]
55+
return [
56+
[minX, minY],
57+
[maxX, maxY],
58+
]
5459
}
5560

5661
const INITIAL_VIEW_STATE: MapViewState = {
@@ -178,17 +183,28 @@ export default function ScoreMap() {
178183
// eslint-disable-next-line react-hooks/exhaustive-deps
179184
}, [selectedCode])
180185

181-
// Deep-link : au 1er chargement des données, si l'URL cible une commune, on
182-
// recentre dessus. Ne se redéclenche pas aux clics suivants (dep [data]).
186+
// Deep-link : dès que données ET carte sont prêtes, si l'URL cible une
187+
// commune, on la cadre. `mapReady` est indispensable : en navigation interne
188+
// les données sortent du cache immédiatement, avant que la carte n'existe.
189+
// Ne se redéclenche pas aux clics suivants (garde didDeepLinkCenter).
190+
const [mapReady, setMapReady] = useState(false)
183191
const didDeepLinkCenter = useRef(false)
184192
useEffect(() => {
185-
if (!data || didDeepLinkCenter.current) return
193+
if (!data || !mapReady || didDeepLinkCenter.current) return
186194
didDeepLinkCenter.current = true
187195
if (selected) {
188-
mapRef.current?.flyTo({ center: geometryCenter(selected.geometry), zoom: 11, duration: 0 })
196+
// Cadre la commune avec une large marge : centré dessus, mais les
197+
// alentours restent bien visibles. maxZoom borne le zoom sur les
198+
// petites communes.
199+
// Même durée d'animation que le recentrage France métro / DROM.
200+
mapRef.current?.fitBounds(geometryBounds(selected.geometry), {
201+
padding: 100,
202+
maxZoom: 11,
203+
duration: 1200,
204+
})
189205
}
190206
// eslint-disable-next-line react-hooks/exhaustive-deps
191-
}, [data])
207+
}, [data, mapReady])
192208

193209
const diverging = DIVERGING_METRICS.has(metric)
194210

@@ -333,6 +349,7 @@ export default function ScoreMap() {
333349
// les mêmes ids ; le diff laisserait des libellés anglais résiduels).
334350
styleDiffing={false}
335351
onStyleData={(e) => syncMapStyle(e.target)}
352+
onLoad={() => setMapReady(true)}
336353
style={{ width: "100%", height: "100%" }}
337354
>
338355
<DeckOverlay layers={layers} />

src/pages/undervalued.tsx

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { useMemo, useState } from "react"
2+
import { Link } from "react-router-dom"
3+
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp } from "lucide-react"
4+
import { useQuery } from "@tanstack/react-query"
5+
6+
import { Button } from "@/components/ui/button"
7+
import { cn } from "@/lib/utils"
8+
import { fetchScore, type ScoreProperties } from "@/lib/score"
9+
10+
// Le livrable actionnable de la problématique (#34) : classement des communes
11+
// « sous-cotées » — gap_pondere positif = bien notée pour son prix. V1 sans
12+
// sparklines (série temporelle des prix : dépend de la couche data #17).
13+
14+
type SortKey = "nom" | "dep" | "prix" | "score_valeur" | "gap_pondere"
15+
16+
const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
17+
{ key: "nom", label: "Commune" },
18+
{ key: "dep", label: "Dép." },
19+
{ key: "prix", label: "€/m²", numeric: true },
20+
{ key: "score_valeur", label: "Score global", numeric: true },
21+
{ key: "gap_pondere", label: "Écart qualité/prix", numeric: true },
22+
]
23+
24+
// Pagination d'affichage : 17 769 communes rendues d'un coup feraient ramer le DOM.
25+
const PAGE = 100
26+
27+
function compare(a: ScoreProperties, b: ScoreProperties, key: SortKey): number {
28+
const va = a[key]
29+
const vb = b[key]
30+
// Les valeurs manquantes vont toujours en fin de liste, quel que soit le sens.
31+
if (va == null) return vb == null ? 0 : 1
32+
if (vb == null) return -1
33+
if (typeof va === "string" || typeof vb === "string")
34+
return String(va).localeCompare(String(vb), "fr")
35+
return va - vb
36+
}
37+
38+
export default function Undervalued() {
39+
const [sortKey, setSortKey] = useState<SortKey>("gap_pondere")
40+
const [desc, setDesc] = useState(true)
41+
const [dep, setDep] = useState("")
42+
const [limit, setLimit] = useState(PAGE)
43+
44+
const { data, isLoading, isError } = useQuery({
45+
queryKey: ["score"], // même cache que la carte : pas de re-téléchargement.
46+
queryFn: fetchScore,
47+
staleTime: Infinity,
48+
})
49+
50+
// Seules les communes notées ET pricées sont classables.
51+
const rows = useMemo(
52+
() =>
53+
(data?.features ?? [])
54+
.map((f) => f.properties)
55+
.filter((p) => p.gap_pondere != null && p.prix != null),
56+
[data],
57+
)
58+
59+
const deps = useMemo(
60+
() =>
61+
[...new Set(rows.map((p) => p.dep).filter((d): d is string => d != null))].sort(
62+
(a, b) => a.localeCompare(b, "fr", { numeric: true }),
63+
),
64+
[rows],
65+
)
66+
67+
const sorted = useMemo(() => {
68+
const filtered = dep ? rows.filter((p) => p.dep === dep) : rows
69+
const s = [...filtered].sort((a, b) => compare(a, b, sortKey))
70+
return desc ? s.reverse() : s
71+
}, [rows, dep, sortKey, desc])
72+
73+
const onSort = (key: SortKey) => {
74+
if (key === sortKey) {
75+
setDesc(!desc)
76+
} else {
77+
setSortKey(key)
78+
// Par défaut : décroissant pour les colonnes numériques, croissant sinon.
79+
setDesc(COLUMNS.find((c) => c.key === key)?.numeric ?? false)
80+
}
81+
setLimit(PAGE)
82+
}
83+
84+
const visible = sorted.slice(0, limit)
85+
86+
return (
87+
<div className="min-h-svh bg-background text-foreground">
88+
<header className="border-b">
89+
<div className="mx-auto flex max-w-5xl items-center gap-3 px-6 py-4">
90+
<Button variant="ghost" size="icon" asChild>
91+
<Link to="/" aria-label="Retour à l'accueil">
92+
<ArrowLeft className="size-4" />
93+
</Link>
94+
</Button>
95+
<span className="font-display text-lg font-bold tracking-tight">
96+
Homepedia<span className="text-accent">.</span>
97+
</span>
98+
</div>
99+
</header>
100+
101+
<main className="mx-auto max-w-5xl px-6 py-10">
102+
<h1 className="font-display text-3xl font-bold tracking-tight">
103+
Communes sous-cotées
104+
</h1>
105+
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
106+
L'écart qualité/prix compare la qualité du territoire (transport,
107+
sécurité, climat, services…) à son niveau de prix.{" "}
108+
<span className="font-medium text-foreground">
109+
Positif = sous-cotée
110+
</span>{" "}
111+
: la commune offre plus que ce que son prix suggère. Négatif = chère
112+
pour ce qu'elle offre.
113+
</p>
114+
115+
<div className="mt-6 flex flex-wrap items-center gap-3">
116+
<label className="flex items-center gap-2 text-sm text-muted-foreground">
117+
Département
118+
<select
119+
value={dep}
120+
onChange={(e) => {
121+
setDep(e.target.value)
122+
setLimit(PAGE)
123+
}}
124+
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
125+
>
126+
<option value="">Tous</option>
127+
{deps.map((d) => (
128+
<option key={d} value={d}>
129+
{d}
130+
</option>
131+
))}
132+
</select>
133+
</label>
134+
<span className="text-xs text-muted-foreground">
135+
{sorted.length.toLocaleString("fr-FR")} communes classées
136+
</span>
137+
</div>
138+
139+
{isLoading && (
140+
<div className="mt-8 text-sm text-muted-foreground">Chargement…</div>
141+
)}
142+
{isError && (
143+
<div className="mt-8 text-sm text-destructive">
144+
Données indisponibles
145+
</div>
146+
)}
147+
148+
{!isLoading && !isError && (
149+
<>
150+
<div className="mt-4 overflow-x-auto rounded-xl border">
151+
<table className="w-full text-sm">
152+
<thead>
153+
<tr className="border-b bg-muted/50 text-left">
154+
<th className="px-3 py-2.5 font-semibold text-muted-foreground">
155+
#
156+
</th>
157+
{COLUMNS.map((c) => (
158+
<th
159+
key={c.key}
160+
className={cn("px-3 py-2.5", c.numeric && "text-right")}
161+
>
162+
<button
163+
type="button"
164+
onClick={() => onSort(c.key)}
165+
className={cn(
166+
"inline-flex items-center gap-1 font-semibold transition-colors hover:text-accent",
167+
sortKey === c.key
168+
? "text-accent"
169+
: "text-muted-foreground",
170+
)}
171+
>
172+
{c.label}
173+
{sortKey === c.key &&
174+
(desc ? (
175+
<ChevronDown className="size-3.5" />
176+
) : (
177+
<ChevronUp className="size-3.5" />
178+
))}
179+
</button>
180+
</th>
181+
))}
182+
<th className="px-3 py-2.5" />
183+
</tr>
184+
</thead>
185+
<tbody>
186+
{visible.map((p, i) => (
187+
<tr
188+
key={p.code_commune}
189+
className="border-b last:border-b-0 transition-colors hover:bg-muted/40"
190+
>
191+
<td className="px-3 py-2 tabular-nums text-muted-foreground">
192+
{i + 1}
193+
</td>
194+
<td className="px-3 py-2">
195+
<div className="font-medium">
196+
{p.nom ?? p.code_commune}
197+
</div>
198+
<div className="text-xs text-muted-foreground">
199+
{p.code_commune}
200+
</div>
201+
</td>
202+
<td className="px-3 py-2 text-muted-foreground">
203+
{p.dep ?? "—"}
204+
</td>
205+
<td className="px-3 py-2 text-right tabular-nums">
206+
{p.prix != null
207+
? Math.round(p.prix).toLocaleString("fr-FR")
208+
: "—"}
209+
</td>
210+
<td className="px-3 py-2 text-right tabular-nums">
211+
{p.score_valeur?.toFixed(2) ?? "—"}
212+
</td>
213+
<td
214+
className={cn(
215+
"px-3 py-2 text-right font-semibold tabular-nums",
216+
(p.gap_pondere ?? 0) > 0
217+
? "text-accent"
218+
: "text-destructive",
219+
)}
220+
>
221+
{(p.gap_pondere ?? 0) >= 0 ? "+" : ""}
222+
{p.gap_pondere?.toFixed(2)}
223+
</td>
224+
<td className="px-3 py-2 text-right">
225+
<Button size="sm" variant="ghost" asChild>
226+
<Link to={`/map?commune=${p.code_commune}`}>
227+
Carte
228+
<ArrowRight className="size-3.5" />
229+
</Link>
230+
</Button>
231+
</td>
232+
</tr>
233+
))}
234+
</tbody>
235+
</table>
236+
</div>
237+
238+
{limit < sorted.length && (
239+
<div className="mt-4 flex justify-center">
240+
<Button
241+
variant="outline"
242+
onClick={() => setLimit(limit + PAGE)}
243+
>
244+
Afficher plus ({(sorted.length - limit).toLocaleString("fr-FR")}{" "}
245+
restantes)
246+
</Button>
247+
</div>
248+
)}
249+
</>
250+
)}
251+
</main>
252+
</div>
253+
)
254+
}

0 commit comments

Comments
 (0)