Skip to content

Commit 7fe780c

Browse files
authored
Merge pull request #40 from T-DAT-902-Homepedia/feat/heatmap-readability
feat(webapp): readable price heatmap with labels above data layers
2 parents a2dac6b + 02f4bcd commit 7fe780c

5 files changed

Lines changed: 164 additions & 64 deletions

File tree

src/components/deck-overlay.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { MapboxOverlay } from "@deck.gl/mapbox"
2+
import type { Layer } from "deck.gl"
3+
import { useControl } from "react-map-gl/maplibre"
4+
import type { IControl } from "maplibre-gl"
5+
6+
/** Overlay deck.gl monté comme contrôle maplibre. En `interleaved`, les couches
7+
* s'insèrent DANS la pile maplibre (via leur prop `beforeId`), donc sous les
8+
* labels du fond de carte qui restent lisibles ; sinon deck dessine sur son
9+
* propre canvas au-dessus de la carte entière.
10+
* `interleaved` est figé à la construction du contrôle : pour en changer,
11+
* remonter le composant (prop React `key`). */
12+
export function DeckOverlay({
13+
layers,
14+
interleaved = true,
15+
}: {
16+
layers: Layer[]
17+
interleaved?: boolean
18+
}) {
19+
const overlay = useControl(
20+
() => new MapboxOverlay({ interleaved, layers }) as unknown as IControl,
21+
)
22+
;(overlay as unknown as MapboxOverlay).setProps({ layers })
23+
return null
24+
}

src/components/map-legend.tsx

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import type { ReactNode } from "react"
22

33
import { DIV_LEGEND } from "@/lib/scoreColors"
4-
import { BIVAR_3X3, rgbaToCss, type RGBA } from "@/lib/palettes"
4+
import {
5+
BIVAR_3X3,
6+
PRICE_HEAT_SEQ,
7+
rgbaToCss,
8+
rgbaToCssAlpha,
9+
type RGBA,
10+
} from "@/lib/palettes"
511
import { formatInt } from "@/lib/format"
612

713
// Légendes de cartes CHIFFRÉES : les bornes affichées sont exactement celles
@@ -184,27 +190,47 @@ export function BubbleLegend({ maxValue, title }: { maxValue: number; title?: st
184190
)
185191
}
186192

187-
/** Légende heatmap : gradient de densité + seuils des isolignes le cas échéant. */
193+
/** Légende heatmap : dégradé construit sur la palette réelle de la couche
194+
* (PRICE_HEAT_SEQ). Mode « prix » : bornes chiffrées du colorDomain (€/m²) ;
195+
* mode « ventes » : densité relative Faible → Forte. */
188196
export function HeatLegend({
189-
weightLabel,
197+
mode,
198+
domain,
190199
contours,
191200
}: {
192-
weightLabel: string
201+
mode: "prix" | "ventes"
202+
/** Bornes réelles du colorDomain en €/m² (mode prix uniquement). */
203+
domain?: [number, number]
193204
contours: boolean
194205
}) {
206+
const stops = PRICE_HEAT_SEQ.map(rgbaToCss)
207+
// En densité, le bas de rampe fond en transparence (threshold de la couche).
208+
const gradient =
209+
mode === "ventes"
210+
? `linear-gradient(to right, ${rgbaToCssAlpha(PRICE_HEAT_SEQ[0], 0)}, ${stops.join(", ")})`
211+
: `linear-gradient(to right, ${stops.join(", ")})`
195212
return (
196-
<LegendCard title={`Densité (${weightLabel})`}>
197-
<div
198-
className="h-2 w-44 rounded-sm"
199-
style={{
200-
background:
201-
"linear-gradient(to right, rgba(255,255,178,0), #fed976, #fd8d3c, #f03b20, #bd0026)",
202-
}}
203-
/>
204-
<div className="mt-0.5 flex w-44 justify-between text-[10px] text-muted-foreground">
205-
<span>Faible</span>
206-
<span>Forte</span>
207-
</div>
213+
<LegendCard
214+
title={mode === "prix" ? "Prix moyen lissé (€/m²)" : "Densité (nombre de ventes)"}
215+
>
216+
<div className="h-2 w-44 rounded-sm" style={{ background: gradient }} />
217+
{mode === "prix" && domain ? (
218+
<>
219+
<div className="mt-0.5 flex w-44 justify-between tabular-nums text-muted-foreground">
220+
<span>{formatInt(domain[0])}</span>
221+
<span>{formatInt((domain[0] + domain[1]) / 2)}</span>
222+
<span>{formatInt(domain[1])}</span>
223+
</div>
224+
<p className="mt-0.5 text-[10px] text-muted-foreground">
225+
Sous {formatInt(domain[0])} €/m² : fondu transparent.
226+
</p>
227+
</>
228+
) : (
229+
<div className="mt-0.5 flex w-44 justify-between text-[10px] text-muted-foreground">
230+
<span>Faible</span>
231+
<span>Forte</span>
232+
</div>
233+
)}
208234
{contours && (
209235
<div className="mt-1.5 space-y-0.5 text-muted-foreground">
210236
{[

src/lib/palettes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export const PRICE_SEQ: RGBA[] = [
2525
[189, 0, 38, 255],
2626
]
2727

28+
/** Variante heatmap : sans le 1er arrêt quasi blanc, illisible en nappe sur
29+
* fond clair (le fondu bas de rampe est assuré par l'alpha du shader). */
30+
export const PRICE_HEAT_SEQ: RGBA[] = PRICE_SEQ.slice(1)
31+
2832
// --- Score & dimensions : séquentiel bleu-violet 6 classes -----------------------
2933

3034
export const SCORE_SEQ: RGBA[] = [
@@ -73,3 +77,6 @@ export const BIVAR_3X3: RGBA[][] = [
7377
]
7478

7579
export const rgbaToCss = (c: RGBA) => `rgb(${c[0]},${c[1]},${c[2]})`
80+
81+
export const rgbaToCssAlpha = (c: RGBA, a: number) =>
82+
`rgba(${c[0]},${c[1]},${c[2]},${a})`

src/pages/dvf-map.tsx

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { useEffect, useMemo, useState } from "react"
2-
import DeckGL from "@deck.gl/react"
1+
import { useEffect, useMemo, useRef, useState } from "react"
32
import { ContourLayer, GeoJsonLayer, HeatmapLayer, ScatterplotLayer } from "deck.gl"
43
import { WebMercatorViewport, type MapViewState } from "@deck.gl/core"
5-
import Map from "react-map-gl/maplibre"
4+
import Map, { type MapRef } from "react-map-gl/maplibre"
5+
import type { Map as MaplibreMap } from "maplibre-gl"
66
import "maplibre-gl/dist/maplibre-gl.css"
77
import { Checkbox } from "radix-ui"
88
import { Check, ChevronRight } from "lucide-react"
99
import { Link, useNavigate, useSearchParams } from "react-router-dom"
1010

1111
import { Button } from "@/components/ui/button"
12+
import { DeckOverlay } from "@/components/deck-overlay"
1213
import { MapTopBar } from "@/components/map-top-bar"
1314
import { useChoropleth } from "@/hooks/useChoropleth"
1415
import {
@@ -33,7 +34,7 @@ import {
3334
} from "@/lib/choropleth"
3435
import { featureBbox, featureCentroids, type Bbox } from "@/lib/centroids"
3536
import { makeColorScale, quantileScale, quantileThresholds } from "@/lib/colorScale"
36-
import { PRICE_SEQ } from "@/lib/palettes"
37+
import { PRICE_HEAT_SEQ, PRICE_SEQ } from "@/lib/palettes"
3738
import {
3839
BubbleLegend,
3940
HeatLegend,
@@ -65,6 +66,11 @@ const MESH_LABEL = { regions: "régions", departements: "départements", commune
6566
// Au-delà de ce zoom, la heatmap laisse place aux mutations individuelles.
6667
const POINTS_ZOOM = 11
6768

69+
// Domaine de couleur du mode « prix » (MEAN, €/m²) : bornes fixes pour que la
70+
// couleur garde le même sens quel que soit le filtre de type ou le chargement.
71+
// Sous la borne basse, l'alpha du shader fond la nappe en transparence.
72+
const HEAT_PRICE_DOMAIN: [number, number] = [1500, 6000]
73+
6874
// --- État dans l'URL (partage/refresh, audit N4) ----------------------------
6975
// v=lat,lng,zoom ; repr= ; type= ; poids= ; iso=1
7076

@@ -86,9 +92,16 @@ interface DrillStep {
8692

8793
export default function DvfMap() {
8894
const [params, setParams] = useSearchParams()
89-
const [viewState, setViewState] = useState<MapViewState>(
95+
// Vue initiale depuis l'URL, figée au montage (la carte est non contrôlée) ;
96+
// viewState devient ensuite un miroir passif alimenté par onMove.
97+
const [initialView] = useState<MapViewState>(
9098
() => parseViewParam(params.get("v")) ?? INITIAL_VIEW_STATE,
9199
)
100+
const [viewState, setViewState] = useState<MapViewState>(initialView)
101+
const mapRef = useRef<MapRef>(null)
102+
// Premier calque de symboles du style : les couches deck passent dessous
103+
// (beforeId), les labels du fond restent lisibles.
104+
const [fillBeforeId, setFillBeforeId] = useState<string | undefined>()
92105
const [hovered, setHovered] = useState<ChoroplethFeature | null>(null)
93106
const [hoveredPoint, setHoveredPoint] = useState<{ prix: number; t: string } | null>(
94107
null,
@@ -100,9 +113,10 @@ export default function DvfMap() {
100113
const setTypeLocal = useFilters((s) => s.setTypeLocal)
101114
const representation = useFilters((s) => s.representation)
102115
const setRepresentation = useFilters((s) => s.setRepresentation)
103-
// Pondération de la heatmap : densité de ventes ou niveau de prix.
116+
// Pondération de la heatmap : niveau de prix (défaut — la page est une carte
117+
// des prix) ou densité de ventes. Les anciens liens ?poids=prix restent valides.
104118
const [heatWeight, setHeatWeight] = useState<"ventes" | "prix">(() =>
105-
params.get("poids") === "prix" ? "prix" : "ventes",
119+
params.get("poids") === "ventes" ? "ventes" : "prix",
106120
)
107121
const [contours, setContours] = useState(() => params.get("iso") === "1")
108122

@@ -134,7 +148,7 @@ export default function DvfMap() {
134148
if (representation !== "choropleth") next.set("repr", representation)
135149
if (typeLocal !== "Tous") next.set("type", typeLocal)
136150
if (representation === "heat") {
137-
if (heatWeight !== "ventes") next.set("poids", heatWeight)
151+
if (heatWeight !== "prix") next.set("poids", heatWeight)
138152
if (contours) next.set("iso", "1")
139153
}
140154
setParams(next, { replace: true })
@@ -170,6 +184,15 @@ export default function DvfMap() {
170184
const visibleDepts = useVisibleDepartements(debouncedBounds)
171185
const high = useCommunesHigh(visibleDepts, highEnabled)
172186

187+
// Animation de cadrage : easeTo maplibre (linéaire, proche de l'ancienne
188+
// transition deck 600 ms) ; onMove alimente viewState pendant l'animation.
189+
const easeToView = (view: MapViewState) =>
190+
mapRef.current?.easeTo({
191+
center: [view.longitude ?? 0, view.latitude ?? 0],
192+
zoom: view.zoom ?? 5,
193+
duration: 600,
194+
})
195+
173196
// Drill-down : clic sur une région/un département -> cadrage sur son emprise
174197
// (la maille suivante prend le relais via meshForZoom) ; sur une commune ->
175198
// fiche détaillée.
@@ -181,8 +204,9 @@ export default function DvfMap() {
181204
}
182205
const bbox = featureBbox(f)
183206
if (!bbox) return
207+
// fitBounds ne dépend que de l'emprise et de la taille d'écran, pas de la
208+
// vue courante.
184209
const viewport = new WebMercatorViewport({
185-
...viewState,
186210
width: window.innerWidth,
187211
height: window.innerHeight,
188212
})
@@ -194,14 +218,12 @@ export default function DvfMap() {
194218
{ padding: 48 },
195219
)
196220
const nextView: MapViewState = {
197-
...viewState,
198221
longitude: target.longitude,
199222
latitude: target.latitude,
200223
// Garantit le changement de maille même sur les grandes emprises (DROM).
201224
zoom: Math.max(target.zoom, f.properties.code_region != null ? 5.6 : 8.1),
202-
transitionDuration: 600,
203225
}
204-
setViewState(nextView)
226+
easeToView(nextView)
205227
const step: DrillStep = { label: f.properties.nom, view: nextView }
206228
setDrillPath((path) =>
207229
f.properties.code_region != null ? [step] : [...path.slice(0, 1), step],
@@ -211,11 +233,7 @@ export default function DvfMap() {
211233
// Retour à une étape du fil d'Ariane (index -1 = France entière).
212234
const goToStep = (index: number) => {
213235
setDrillPath((path) => path.slice(0, index + 1))
214-
setViewState(
215-
index < 0
216-
? { ...INITIAL_VIEW_STATE, transitionDuration: 600 }
217-
: { ...drillPath[index].view, transitionDuration: 600 },
218-
)
236+
easeToView(index < 0 ? INITIAL_VIEW_STATE : drillPath[index].view)
219237
}
220238

221239
// Accessors mémoïsés : changer de type recolore la couche sans la recréer ni
@@ -269,12 +287,16 @@ export default function DvfMap() {
269287
}, [showPoints, heatPoints])
270288

271289
const layers = useMemo(() => {
290+
// beforeId glisse chaque couche sous les labels du fond (prop runtime de
291+
// @deck.gl/mapbox, non typée en v8) ; spread conditionnel pour le type.
292+
const underLabels = fillBeforeId ? { beforeId: fillBeforeId } : {}
272293
if (heat) {
273294
if (showPoints && pointColor) {
274295
return [
275296
new ScatterplotLayer({
276297
id: "transactions-points",
277298
data: heatPoints,
299+
...underLabels,
278300
getPosition: (pt: { lon: number; lat: number }) => [pt.lon, pt.lat],
279301
getFillColor: (pt: { prix: number }) => pointColor.color(pt.prix),
280302
radiusUnits: "pixels",
@@ -291,21 +313,35 @@ export default function DvfMap() {
291313
}),
292314
]
293315
}
316+
// Mode « prix » : MEAN lit le colorDomain tel quel en €/m² (seul SUM le
317+
// rescale en m/px), intensity doit donc rester à 1 sous peine de décaler
318+
// le domaine ; opacité réduite pour garder le fond lisible sous la nappe.
319+
// Mode « ventes » : intensity > 1 sature Paris et révèle le reste (la
320+
// normalisation se fait sur le max de densité à l'écran).
321+
const prixMode = heatWeight === "prix"
294322
const heatmap = new HeatmapLayer({
295323
id: "transactions-heatmap",
296324
data: heatPoints,
325+
...underLabels,
297326
getPosition: (p: { lon: number; lat: number }) => [p.lon, p.lat],
298-
getWeight:
299-
heatWeight === "prix" ? (p: { prix: number }) => p.prix : () => 1,
327+
colorRange: PRICE_HEAT_SEQ,
300328
// Rayon resserré à mesure que l'on zoome : le halo reste local.
301-
radiusPixels: Math.min(45, Math.max(15, 60 - 4 * zoomDebounced)),
302-
aggregation: "SUM",
329+
radiusPixels: Math.min(28, Math.max(10, 38 - 2.8 * zoomDebounced)),
330+
getWeight: prixMode ? (p: { prix: number }) => p.prix : () => 1,
331+
aggregation: prixMode ? "MEAN" : "SUM",
332+
colorDomain: prixMode ? HEAT_PRICE_DOMAIN : null,
333+
intensity: prixMode ? 1 : 2,
334+
// Fondu du bas de rampe ; sans effet en mode prix (colorDomain prime).
335+
threshold: 0.04,
336+
opacity: prixMode ? 0.6 : 0.8,
337+
updateTriggers: { getWeight: [heatWeight] },
303338
})
304339
if (!contours) return [heatmap]
305340
// Isolignes de densité (nb de ventes par cellule d'environ 4 km).
306341
const contour = new ContourLayer({
307342
id: "transactions-contours",
308343
data: heatPoints,
344+
...underLabels,
309345
getPosition: (p: { lon: number; lat: number }) => [p.lon, p.lat],
310346
cellSize: 4000,
311347
contours: [
@@ -322,6 +358,7 @@ export default function DvfMap() {
322358
// reconstruire la couche, seulement repasser dans getFillColor.
323359
id: `choropleth-${mesh}-${lod}`,
324360
data: data ?? EMPTY_COLLECTION,
361+
...underLabels,
325362
filled: true,
326363
stroked: true,
327364
// En mode bulles, la choroplèthe devient un simple fond de repérage.
@@ -344,6 +381,7 @@ export default function DvfMap() {
344381
const highLayer = new GeoJsonLayer<ChoroplethFeature["properties"]>({
345382
id: "choropleth-communes-high",
346383
data: { type: "FeatureCollection" as const, features: high.features },
384+
...underLabels,
347385
filled: true,
348386
stroked: true,
349387
getFillColor: (f) => colorScale.getColor(f as ChoroplethFeature),
@@ -361,6 +399,7 @@ export default function DvfMap() {
361399
const bubbles = new ScatterplotLayer({
362400
id: `bubbles-${mesh}`,
363401
data: centroids,
402+
...underLabels,
364403
getPosition: (c: (typeof centroids)[number]) => c.position,
365404
// Aire proportionnelle au volume (racine du nb de transactions).
366405
getRadius: (c: (typeof centroids)[number]) =>
@@ -410,8 +449,25 @@ export default function DvfMap() {
410449
zoomDebounced,
411450
showPoints,
412451
pointColor,
452+
fillBeforeId,
413453
])
414454

455+
// Au (re)chargement du style, mémorise le 1er calque de symboles : c'est le
456+
// beforeId sous lequel glisser les couches deck (labels au-dessus). Idempotent
457+
// (même id -> pas de re-render), style Positron unique sur cette page.
458+
const onStyleData = (map: MaplibreMap) => {
459+
const symbol = (map.getStyle()?.layers ?? []).find((l) => l.type === "symbol")
460+
setFillBeforeId(symbol?.id)
461+
}
462+
463+
// Remplace le getCursor de DeckGL : pointer au survol d'une entité pickable,
464+
// sinon retour au grab/grabbing CSS de maplibre (chaîne vide).
465+
const hovering = heat ? showPoints && hoveredPoint != null : hovered != null
466+
useEffect(() => {
467+
const canvas = mapRef.current?.getCanvas()
468+
if (canvas) canvas.style.cursor = hovering ? "pointer" : ""
469+
}, [hovering])
470+
415471
const hoveredStats = hovered ? statsForType(hovered.properties, typeLocal) : null
416472
const loading = isLoading || (heat && pointsLoading)
417473

@@ -426,16 +482,16 @@ export default function DvfMap() {
426482
</Button>
427483
}
428484
/>
429-
<DeckGL
430-
viewState={viewState}
431-
onViewStateChange={(e) => setViewState(e.viewState as MapViewState)}
432-
controller
433-
layers={layers}
434-
getCursor={({ isHovering }) => (isHovering ? "pointer" : "grab")}
485+
<Map
486+
ref={mapRef}
487+
initialViewState={initialView}
488+
mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
489+
onMove={(e) => setViewState(e.viewState)}
490+
onStyleData={(e) => onStyleData(e.target)}
435491
style={{ width: "100%", height: "100%" }}
436492
>
437-
<Map mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" />
438-
</DeckGL>
493+
<DeckOverlay layers={layers} />
494+
</Map>
439495

440496
{/* Panneau de contrôle */}
441497
<div className="absolute top-16 left-4 rounded-xl border bg-background/95 p-4 shadow-lg backdrop-blur">
@@ -567,7 +623,8 @@ export default function DvfMap() {
567623
/>
568624
) : (
569625
<HeatLegend
570-
weightLabel={heatWeight === "prix" ? "pondérée par le prix" : "nombre de ventes"}
626+
mode={heatWeight}
627+
domain={heatWeight === "prix" ? HEAT_PRICE_DOMAIN : undefined}
571628
contours={contours}
572629
/>
573630
)

0 commit comments

Comments
 (0)