@@ -20,7 +20,12 @@ import {
2020 type Metric ,
2121 type ScoreFeature ,
2222} from "@/lib/score"
23- import { makeDivergingScale , makeSequentialScale } from "@/lib/scoreColors"
23+ import {
24+ BIVAR_CLASS_LABELS ,
25+ makeBivariateScale ,
26+ makeDivergingScale ,
27+ makeSequentialScale ,
28+ } from "@/lib/scoreColors"
2429import { ScoreSidebar , type Basemap , type MapView } from "@/components/score-sidebar"
2530import { loadWordCloud , type CityWordCloud } from "@/lib/parseAvis"
2631import WordCloudPopup from "@/components/WordCloudPopup"
@@ -120,6 +125,9 @@ function DeckOverlay({ layers }: { layers: Layer[] }) {
120125export default function ScoreMap ( ) {
121126 const [ hovered , setHovered ] = useState < ScoreFeature | null > ( null )
122127 const [ metric , setMetric ] = useState < Metric > ( "score_valeur" )
128+ // Mode bivarié : croise `metric` (axe x) avec `metricY` (axe y) sur 3×3 classes.
129+ const [ bivariate , setBivariate ] = useState ( false )
130+ const [ metricY , setMetricY ] = useState < Metric > ( "n_prix" )
123131 const [ opacity , setOpacity ] = useState ( 0.8 )
124132 const [ basemap , setBasemap ] = useState < Basemap > ( "satellite" )
125133 const [ fillBeforeId , setFillBeforeId ] = useState < string | undefined > ( )
@@ -190,6 +198,16 @@ export default function ScoreMap() {
190198 return diverging ? makeDivergingScale ( values ) : makeSequentialScale ( values )
191199 } , [ data , metric , diverging ] )
192200
201+ // Échelle bivariée (terciles sur chaque axe), seulement en mode bivarié.
202+ const bivarScale = useMemo ( ( ) => {
203+ if ( ! bivariate ) return null
204+ const fs = data ?. features ?? [ ]
205+ return makeBivariateScale (
206+ fs . map ( ( f ) => f . properties [ metric ] ) ,
207+ fs . map ( ( f ) => f . properties [ metricY ] ) ,
208+ )
209+ } , [ data , bivariate , metric , metricY ] )
210+
193211 const layers = useMemo ( ( ) => {
194212 const choropleth = new GeoJsonLayer < ScoreFeature [ "properties" ] > ( {
195213 id : "score-choropleth" ,
@@ -200,7 +218,12 @@ export default function ScoreMap() {
200218 filled : true ,
201219 stroked : true ,
202220 opacity,
203- getFillColor : ( f ) => scale ( ( f as ScoreFeature ) . properties [ metric ] ) ,
221+ getFillColor : ( f ) => {
222+ const p = ( f as ScoreFeature ) . properties
223+ return bivarScale
224+ ? bivarScale . color ( p [ metric ] , p [ metricY ] )
225+ : scale ( p [ metric ] )
226+ } ,
204227 getLineColor : [ 255 , 255 , 255 , 120 ] ,
205228 lineWidthMinPixels : 0.5 ,
206229 pickable : true ,
@@ -210,7 +233,7 @@ export default function ScoreMap() {
210233 selectCommune ( ( info . object as ScoreFeature ) . properties . code_commune ) ,
211234 // metric + scale doivent déclencher le recalcul des couleurs (sinon
212235 // deck.gl garde l'ancienne palette en cache).
213- updateTriggers : { getFillColor : [ scale , metric ] } ,
236+ updateTriggers : { getFillColor : [ scale , bivarScale , metric , metricY ] } ,
214237 } )
215238
216239 // Contour de la commune sélectionnée, au-dessus (ambre, visible sur tout fond).
@@ -243,9 +266,14 @@ export default function ScoreMap() {
243266 setSelectedCity ( ( info . object as CityWordCloud ) ?? null ) ,
244267 } )
245268 return [ choropleth , highlight , wordCloudLayer ]
246- } , [ data , scale , metric , opacity , fillBeforeId , selected , wordCloudEnabled , cities ] )
269+ } , [ data , scale , bivarScale , metric , metricY , opacity , fillBeforeId , selected , wordCloudEnabled , cities ] )
247270
248271 const hoveredValue = hovered ?. properties [ metric ]
272+ // Classes bivariées de la commune survolée, pour la ligne « Classe » du tooltip.
273+ const hoveredClasses =
274+ hovered && bivarScale
275+ ? bivarScale . classes ( hovered . properties [ metric ] , hovered . properties [ metricY ] )
276+ : null
249277
250278 // À chaque (re)chargement de style — initial OU changement de fond — repasse les
251279 // labels en français et mémorise le 1er calque de symboles (beforeId). Le
@@ -285,6 +313,10 @@ export default function ScoreMap() {
285313 basemap = { basemap }
286314 onBasemap = { setBasemap }
287315 onCenter = { centerOn }
316+ bivariate = { bivariate }
317+ onBivariate = { setBivariate }
318+ metricY = { metricY }
319+ onMetricY = { setMetricY }
288320 isLoading = { isLoading }
289321 isError = { isError }
290322 wordCloudEnabled = { wordCloudEnabled }
@@ -319,6 +351,20 @@ export default function ScoreMap() {
319351 { fmt ( metric , hoveredValue ) }
320352 </ span >
321353 </ div >
354+ { bivariate && (
355+ < div >
356+ { METRIC_LABELS [ metricY ] } :{ " " }
357+ < span className = "font-semibold text-accent" >
358+ { fmt ( metricY , hovered . properties [ metricY ] ) }
359+ </ span >
360+ </ div >
361+ ) }
362+ { hoveredClasses && (
363+ < div className = "text-muted-foreground" >
364+ Classe : { BIVAR_CLASS_LABELS [ hoveredClasses [ 0 ] ] } ×{ " " }
365+ { BIVAR_CLASS_LABELS [ hoveredClasses [ 1 ] ] }
366+ </ div >
367+ ) }
322368 < div className = "text-muted-foreground" >
323369 { hovered . properties . prix != null
324370 ? `${ Math . round ( hovered . properties . prix ) . toLocaleString ( "fr-FR" ) } €/m²`
0 commit comments