Skip to content

Commit 614d658

Browse files
authored
Merge pull request #31 from T-DAT-902-Homepedia/feat/bivariate-choropleth
feat: bivariate choropleth crossing two metrics (#14)
2 parents 5ae4d9e + 5a3b712 commit 614d658

4 files changed

Lines changed: 227 additions & 28 deletions

File tree

src/components/score-sidebar.tsx

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
METRIC_LABELS,
1010
type Metric,
1111
} from "@/lib/score"
12-
import { DIV_LEGEND, SEQ_LEGEND } from "@/lib/scoreColors"
12+
import { BIVAR_PALETTE, DIV_LEGEND, SEQ_LEGEND } from "@/lib/scoreColors"
1313

1414
export type Basemap = "clair" | "sombre" | "satellite" | "couleur"
1515
export const BASEMAP_LABELS: Record<Basemap, string> = {
@@ -43,6 +43,10 @@ type ScoreSidebarProps = {
4343
basemap: Basemap
4444
onBasemap: (b: Basemap) => void
4545
onCenter: (view: MapView) => void
46+
bivariate: boolean
47+
onBivariate: (v: boolean) => void
48+
metricY: Metric
49+
onMetricY: (m: Metric) => void
4650
isLoading: boolean
4751
isError: boolean
4852
wordCloudEnabled: boolean
@@ -112,6 +116,10 @@ export function ScoreSidebar({
112116
basemap,
113117
onBasemap,
114118
onCenter,
119+
bivariate,
120+
onBivariate,
121+
metricY,
122+
onMetricY,
115123
isLoading,
116124
isError,
117125
wordCloudEnabled,
@@ -169,32 +177,90 @@ export function ScoreSidebar({
169177
})}
170178
</div>
171179

172-
{/* Légende du dégradé */}
173-
<div className="mt-3">
174-
<div className="flex h-2 overflow-hidden rounded-sm">
175-
{legend.map((c, i) => (
176-
<div
177-
key={i}
178-
className="flex-1"
179-
style={{ backgroundColor: `rgb(${c[0]},${c[1]},${c[2]})` }}
180-
/>
180+
{/* Mode bivarié : croise la métrique courante avec une seconde. */}
181+
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm">
182+
<Checkbox.Root
183+
checked={bivariate}
184+
onCheckedChange={(v) => onBivariate(v === true)}
185+
className="flex size-4 shrink-0 items-center justify-center rounded border border-input bg-background data-[state=checked]:border-accent data-[state=checked]:bg-accent"
186+
>
187+
<Checkbox.Indicator>
188+
<Check className="size-3 text-accent-foreground" />
189+
</Checkbox.Indicator>
190+
</Checkbox.Root>
191+
Croiser avec une 2ᵉ métrique
192+
<InfoTip
193+
label="Mode bivarié"
194+
text="Croise deux métriques sur une palette 3×3 pour repérer par exemple les communes « bien notées et abordables »."
195+
/>
196+
</label>
197+
{bivariate && (
198+
<select
199+
value={metricY}
200+
onChange={(e) => onMetricY(e.target.value as Metric)}
201+
aria-label="Seconde métrique"
202+
className="mt-2 w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm"
203+
>
204+
{metrics.map((m) => (
205+
<option key={m} value={m}>
206+
{METRIC_LABELS[m]}
207+
</option>
181208
))}
209+
</select>
210+
)}
211+
212+
{/* Légende : matrice 3×3 en bivarié, dégradé sinon. */}
213+
{bivariate ? (
214+
<div className="mt-3 flex items-end gap-2.5">
215+
{/* Lignes du haut vers le bas = classe y décroissante (élevé en haut). */}
216+
<div className="flex flex-col gap-px">
217+
{[2, 1, 0].map((y) => (
218+
<div key={y} className="flex gap-px">
219+
{[0, 1, 2].map((x) => {
220+
const c = BIVAR_PALETTE[y][x]
221+
return (
222+
<div
223+
key={x}
224+
className="size-4"
225+
style={{ backgroundColor: `rgb(${c[0]},${c[1]},${c[2]})` }}
226+
/>
227+
)
228+
})}
229+
</div>
230+
))}
231+
</div>
232+
<div className="space-y-0.5 text-[10px] leading-tight text-muted-foreground">
233+
<div>{METRIC_LABELS[metric]} (faible → élevé)</div>
234+
<div>{METRIC_LABELS[metricY]} (faible → élevé)</div>
235+
</div>
182236
</div>
183-
<div className="mt-0.5 flex justify-between text-[10px] text-muted-foreground">
184-
{diverging ? (
185-
<>
186-
<span>Cher</span>
187-
<span>Neutre</span>
188-
<span>Bon rapport</span>
189-
</>
190-
) : (
191-
<>
192-
<span>Faible</span>
193-
<span>Élevé</span>
194-
</>
195-
)}
237+
) : (
238+
<div className="mt-3">
239+
<div className="flex h-2 overflow-hidden rounded-sm">
240+
{legend.map((c, i) => (
241+
<div
242+
key={i}
243+
className="flex-1"
244+
style={{ backgroundColor: `rgb(${c[0]},${c[1]},${c[2]})` }}
245+
/>
246+
))}
247+
</div>
248+
<div className="mt-0.5 flex justify-between text-[10px] text-muted-foreground">
249+
{diverging ? (
250+
<>
251+
<span>Cher</span>
252+
<span>Neutre</span>
253+
<span>Bon rapport</span>
254+
</>
255+
) : (
256+
<>
257+
<span>Faible</span>
258+
<span>Élevé</span>
259+
</>
260+
)}
261+
</div>
196262
</div>
197-
</div>
263+
)}
198264

199265
{isLoading && (
200266
<div className="mt-2 text-xs text-muted-foreground">Chargement…</div>

src/lib/scoreColors.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { BIVAR_PALETTE, makeBivariateScale } from "@/lib/scoreColors"
4+
5+
describe("makeBivariateScale", () => {
6+
// 9 valeurs uniformes 1..9 sur chaque axe : terciles nets (1-3, 4-6, 7-9).
7+
const vals = [1, 2, 3, 4, 5, 6, 7, 8, 9]
8+
9+
it("classe les valeurs en terciles sur chaque axe", () => {
10+
const s = makeBivariateScale(vals, vals)
11+
expect(s.classes(1, 1)).toEqual([0, 0])
12+
expect(s.classes(5, 5)).toEqual([1, 1])
13+
expect(s.classes(9, 9)).toEqual([2, 2])
14+
expect(s.classes(9, 1)).toEqual([2, 0])
15+
})
16+
17+
it("mappe les classes sur la palette [y][x]", () => {
18+
const s = makeBivariateScale(vals, vals)
19+
expect(s.color(1, 1)).toEqual(BIVAR_PALETTE[0][0])
20+
expect(s.color(9, 9)).toEqual(BIVAR_PALETTE[2][2])
21+
// x élevé, y faible -> colonne 2, ligne 0.
22+
expect(s.color(9, 1)).toEqual(BIVAR_PALETTE[0][2])
23+
})
24+
25+
it("retourne no-data si une des deux valeurs manque", () => {
26+
const s = makeBivariateScale(vals, vals)
27+
expect(s.classes(null, 5)).toBeNull()
28+
expect(s.classes(5, undefined)).toBeNull()
29+
expect(s.color(null, 5)[3]).toBeLessThan(255) // gris translucide « no data »
30+
})
31+
32+
it("reste stable avec moins de 2 valeurs (tout en classe 0)", () => {
33+
const s = makeBivariateScale([0.5], [])
34+
expect(s.classes(0.5, 0.5)).toEqual([0, 0])
35+
})
36+
})

src/lib/scoreColors.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,54 @@ export function makeDivergingScale(values: (number | null | undefined)[]) {
6161
export const SEQ_LEGEND = SEQ_PALETTE
6262
// Rampe divergente pour la légende (NEG -> MID -> POS).
6363
export const DIV_LEGEND: RGBA[] = [NEG, lerp(MID, NEG, 0.5), MID, lerp(MID, POS, 0.5), POS]
64+
65+
// --- Bivarié (croisement de 2 métriques, 3×3) -----------------------------------
66+
67+
// Palette bivariée 3×3 « bleu-violet » (Joshua Stevens) : BIVAR_PALETTE[y][x],
68+
// x = classe de la 1re métrique (colonnes), y = classe de la 2e (lignes),
69+
// classe 0 = faible. Le coin [2][2] (élevé × élevé) est le plus saturé.
70+
export const BIVAR_PALETTE: RGBA[][] = [
71+
[[232, 232, 232, 255], [172, 228, 228, 255], [90, 200, 200, 255]],
72+
[[223, 176, 214, 255], [165, 173, 211, 255], [86, 152, 185, 255]],
73+
[[190, 100, 172, 255], [140, 98, 170, 255], [59, 73, 148, 255]],
74+
]
75+
76+
export const BIVAR_CLASS_LABELS = ["faible", "moyen", "élevé"] as const
77+
78+
// Seuils de terciles rang-based (même approche que quantileScale, mais ici on a
79+
// besoin de l'indice de classe pour le tooltip, pas seulement de la couleur).
80+
function tercileThresholds(values: (number | null | undefined)[]): number[] {
81+
const sorted = values.filter((v): v is number => v != null).sort((a, b) => a - b)
82+
if (sorted.length < 2) return []
83+
return [1, 2].map((i) => sorted[Math.floor((i / 3) * (sorted.length - 1))])
84+
}
85+
86+
function classOf(thresholds: number[], v: number): number {
87+
let c = 0
88+
while (c < thresholds.length && v > thresholds[c]) c++
89+
return c
90+
}
91+
92+
/** Échelle bivariée 3×3 par terciles : couleur + classes (pour le tooltip).
93+
* Une valeur manquante sur l'un des deux axes -> gris « no data ». */
94+
export function makeBivariateScale(
95+
xs: (number | null | undefined)[],
96+
ys: (number | null | undefined)[],
97+
) {
98+
const tx = tercileThresholds(xs)
99+
const ty = tercileThresholds(ys)
100+
return {
101+
/** Classes [x, y] dans {0,1,2}, ou null si l'une des valeurs manque. */
102+
classes(
103+
x: number | null | undefined,
104+
y: number | null | undefined,
105+
): [number, number] | null {
106+
if (x == null || y == null) return null
107+
return [classOf(tx, x), classOf(ty, y)]
108+
},
109+
color(x: number | null | undefined, y: number | null | undefined): RGBA {
110+
const c = this.classes(x, y)
111+
return c ? BIVAR_PALETTE[c[1]][c[0]] : NO_DATA
112+
},
113+
}
114+
}

src/pages/score-map.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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"
2429
import { ScoreSidebar, type Basemap, type MapView } from "@/components/score-sidebar"
2530
import { loadWordCloud, type CityWordCloud } from "@/lib/parseAvis"
2631
import WordCloudPopup from "@/components/WordCloudPopup"
@@ -120,6 +125,9 @@ function DeckOverlay({ layers }: { layers: Layer[] }) {
120125
export 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

Comments
 (0)