1- import { useEffect , useMemo , useState } from "react"
2- import DeckGL from "@deck.gl/react"
1+ import { useEffect , useMemo , useRef , useState } from "react"
32import { ContourLayer , GeoJsonLayer , HeatmapLayer , ScatterplotLayer } from "deck.gl"
43import { 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"
66import "maplibre-gl/dist/maplibre-gl.css"
77import { Checkbox } from "radix-ui"
88import { Check , ChevronRight } from "lucide-react"
99import { Link , useNavigate , useSearchParams } from "react-router-dom"
1010
1111import { Button } from "@/components/ui/button"
12+ import { DeckOverlay } from "@/components/deck-overlay"
1213import { MapTopBar } from "@/components/map-top-bar"
1314import { useChoropleth } from "@/hooks/useChoropleth"
1415import {
@@ -33,7 +34,7 @@ import {
3334} from "@/lib/choropleth"
3435import { featureBbox , featureCentroids , type Bbox } from "@/lib/centroids"
3536import { makeColorScale , quantileScale , quantileThresholds } from "@/lib/colorScale"
36- import { PRICE_SEQ } from "@/lib/palettes"
37+ import { PRICE_HEAT_SEQ , PRICE_SEQ } from "@/lib/palettes"
3738import {
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.
6667const 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
8793export 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