Skip to content

Commit 8975715

Browse files
authored
Merge pull request #44 from T-DAT-902-Homepedia/feat/iris-quartier-layer
feat(webapp): IRIS neighborhood choropleth layer on price and score maps
2 parents 19ae63b + 153bc68 commit 8975715

12 files changed

Lines changed: 677 additions & 43 deletions

File tree

docs/data-cdn.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@ BASE = https://storage.googleapis.com/homepedia-web/v1
2121
"nb_communes": 34933,
2222
"nb_communes_scorees": 17774,
2323
"nb_communes_avis": 78,
24+
"nb_iris": 16400,
25+
"nb_iris_scores": 14500,
2426
"generated_at": "2026-07-07T20:08:24+00:00"
2527
}
2628
```
2729

2830
`nb_communes_avis` (0 ou absent = run sans analyse d'avis) sert de feature-gate à
29-
la section « avis » ; `schema_version` reste à 1 tant que les évolutions sont
30-
additives (le schéma zod tolère un futur bump).
31+
la section « avis » ; `nb_iris` (0 ou absent = run sans maille quartier) sert de
32+
feature-gate à la couche IRIS des cartes ; `schema_version` reste à 1 tant que
33+
les évolutions sont additives (le schéma zod tolère un futur bump).
3134

3235
- Tout le reste est sous `{BASE}/{meta.base}/…`, **immuable** (cache-control 1 an,
3336
`immutable`) :
@@ -37,6 +40,10 @@ additives (le schéma zod tolère un futur bump).
3740
- `choropleth/departements-low.geojson` (~120 Ko gz), `choropleth/departements-mid.geojson` (~800 Ko gz)
3841
- `choropleth/communes-mid.geojson` (~3,4 Mo gz, 34 928 features, national)
3942
- `choropleth/communes-high/{dept}.geojson` ×101 (`{dept}` ∈ 01…95, 2A, 2B, 971…976)
43+
- `choropleth/iris-high/{dept}.geojson` ×~104 (~80 Ko gz, max ~400 Ko) — maille
44+
quartier (IRIS INSEE, ADR-0015 pipeline), **communes multi-IRIS uniquement**
45+
(1 944 villes) : la couche se superpose à la maille commune, **404 = normal**
46+
(département sans ville multi-IRIS)
4047
- `communes/{dept}.json` ×101 (fiches commune, groupées par département —
4148
incluent le bloc `avis` résumé quand la commune est couverte)
4249
- `avis/{dept}.json` (départements couverts uniquement, **404 = normal**) :
@@ -70,6 +77,18 @@ dimensions normalisées `n_*` (toutes nullables). Le `v1/score.geojson` racine e
7077
**déprécié** : `src/lib/score.ts` adapte désormais la choroplèthe communale
7178
versionnée à son ancien format.
7279

80+
**Properties GeoJSON quartiers (IRIS)** : identité `code_iris`, `nom` (nom du
81+
quartier), `code_commune`, `nom_commune`, `type_iris`, `code_departement` ;
82+
données `prix_m2_median` (entier), `nb_transactions`, `fiable`,
83+
`annee_min`/`annee_max` ; score `score_commune`, `n_prix_iris`, `gap_iris`,
84+
`gap_pondere_iris` (3 décimales). Les noms diffèrent de la maille commune :
85+
`score_valeur``score_commune` (hérité de la ville), `gap``gap_iris`,
86+
`gap_pondere``gap_pondere_iris` (mapping dans `src/lib/iris.ts`). Le prix
87+
est une **médiane poolée** sur `annee_min``annee_max` : ne pas le comparer
88+
chiffre à chiffre au prix communal (millésime courant). `gap_iris > 0` =
89+
quartier sous-coté par rapport à la qualité de vie de sa ville ;
90+
`fiable = false` → valeurs à null (« pas de donnée », comme les communes).
91+
7392
## Sémantique métier
7493

7594
- `score_valeur`[0,1] : qualité de vie composite. Poids : **emploi 30 %, proximité
@@ -85,7 +104,9 @@ versionnée à son ancien format.
85104

86105
1. **Paris (75056), Lyon (69123), Marseille (13055) n'ont pas d'agrégat prix/évolution
87106
au niveau commune** : les transactions DVF sont codées par arrondissement (751xx,
88-
6938x, 132xx), qui sont eux des features/fiches normales.
107+
6938x, 132xx), qui sont eux des features/fiches normales. Les quartiers IRIS
108+
portent aussi les codes arrondissements : la couche quartier affiche des
109+
données sur Paris là où la choroplèthe communale montre « pas de donnée ».
89110
2. **5 fiches sans géométrie** (communes fusionnées entre millésimes) : atteignables par
90111
recherche/fiche mais pas par la carte.
91112
3. **Dérivation du département depuis un code commune** : 3 premiers caractères si

src/components/iris-tooltip.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ReactNode } from "react"
2+
3+
import type { IrisProperties } from "@/lib/iris"
4+
import { formatEuroM2, formatInt, formatScore, formatSigned } from "@/lib/format"
5+
6+
/**
7+
* Corps du tooltip quartier (IRIS), partagé par les deux cartes. Le prix est
8+
* une médiane poolée sur plusieurs millésimes : on affiche sa période plutôt
9+
* que de l'opposer chiffre à chiffre au prix communal (millésime courant).
10+
* Le score est hérité de la commune — mention explicite. `children` s'insère
11+
* après le titre (ex. : la métrique courante de la carte score).
12+
*/
13+
export function IrisTooltipContent({
14+
p,
15+
clickHint,
16+
children,
17+
}: {
18+
p: IrisProperties
19+
clickHint?: string
20+
children?: ReactNode
21+
}) {
22+
const periode =
23+
p.annee_min != null && p.annee_max != null
24+
? ` ${p.annee_min}${p.annee_max}`
25+
: ""
26+
return (
27+
<>
28+
<div className="font-display font-semibold">{p.nom}</div>
29+
<div className="text-xs text-muted-foreground">
30+
Quartier de {p.nom_commune}
31+
</div>
32+
{children}
33+
<div className="mt-1">
34+
Prix médian{periode} :{" "}
35+
<span className="font-semibold text-accent">
36+
{formatEuroM2(p.prix_m2_median)}
37+
</span>
38+
</div>
39+
<div className="text-muted-foreground">
40+
{formatInt(p.nb_transactions)} transactions
41+
{p.fiable ? "" : " (faible volume)"}
42+
</div>
43+
<div className="text-muted-foreground">
44+
Écart qualité/prix : {formatSigned(p.gap_pondere_iris)}
45+
</div>
46+
<div className="text-muted-foreground">
47+
Score : {formatScore(p.score_commune)} — score de la commune
48+
</div>
49+
{clickHint && (
50+
<div className="mt-1 text-xs text-muted-foreground">{clickHint}</div>
51+
)}
52+
</>
53+
)
54+
}

src/components/score-sidebar.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ type ScoreSidebarProps = {
6262
isError: boolean
6363
wordCloudEnabled: boolean
6464
onWordCloudEnabled: (v: boolean) => void
65+
/** Couche quartier (IRIS) actuellement visible sur la carte. */
66+
irisActive?: boolean
6567
}
6668

6769
/** Bulle d'aide « i » réutilisée sur chaque métrique et titre de section. */
@@ -138,6 +140,7 @@ function SidebarContent({
138140
isError,
139141
wordCloudEnabled,
140142
onWordCloudEnabled,
143+
irisActive,
141144
}: ScoreSidebarProps) {
142145
return (
143146
<>
@@ -248,6 +251,13 @@ function SidebarContent({
248251
)}
249252
</div>
250253

254+
{irisActive && (
255+
<p className="mt-2 text-[10px] text-muted-foreground">
256+
Maille quartier (IRIS) sur les grandes villes — score hérité de
257+
la commune, prix et écart propres au quartier.
258+
</p>
259+
)}
260+
251261
{isLoading && (
252262
<div className="mt-2 text-xs text-muted-foreground">Chargement…</div>
253263
)}

src/hooks/useCommunesHigh.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ export const MAX_HIGH_DEPTS = 15
1414
/**
1515
* Codes des départements dont l'emprise intersecte le viewport, triés
1616
* (identité stable pour les queryKeys). Source : la choroplèthe
17-
* départementale low, déjà en cache après la vue initiale.
17+
* départementale low, déjà en cache après la vue initiale. `enabled` évite
18+
* son fetch tant qu'aucun consommateur n'en a besoin (carte score).
1819
*/
19-
export function useVisibleDepartements(viewBounds: Bbox | null): string[] {
20-
const { data: depts } = useChoropleth("departements", "low")
20+
export function useVisibleDepartements(viewBounds: Bbox | null, enabled = true): string[] {
21+
const { data: depts } = useChoropleth("departements", "low", enabled)
2122
const bboxes = useMemo(
2223
() => (depts ? deptBboxes(depts.features) : new Map<string, Bbox>()),
2324
[depts],

src/hooks/useIrisHigh.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useQueries } from "@tanstack/react-query"
2+
3+
import { fetchIrisDept, type IrisFeature } from "@/lib/iris"
4+
import { MAX_HIGH_DEPTS } from "@/hooks/useCommunesHigh"
5+
import { useMeta } from "@/hooks/useMeta"
6+
7+
/** Seuil de zoom « quartier » : au-dessus du high commune (10), là où les
8+
* IRIS deviennent lisibles. */
9+
export const IRIS_ZOOM_THRESHOLD = 11
10+
11+
/** Feature-gate : run publiant la maille quartier (clés meta additives). */
12+
export function useHasIris(): boolean {
13+
const { data: meta } = useMeta()
14+
return (meta?.nb_iris ?? 0) > 0
15+
}
16+
17+
/**
18+
* IRIS des départements demandés, fusionnés. Même mécanique que
19+
* useCommunesHigh : un fetch par département, caché indéfiniment. Le
20+
* feature-gate nb_iris est interne : sur un ancien run, zéro requête.
21+
*/
22+
export function useIrisHigh(depts: string[], enabled: boolean) {
23+
const { data: meta } = useMeta()
24+
const hasIris = useHasIris()
25+
const capped =
26+
enabled && hasIris && depts.length <= MAX_HIGH_DEPTS ? depts : []
27+
return useQueries({
28+
queries: capped.map((dept) => ({
29+
queryKey: ["choropleth", meta?.base, "iris-high", dept],
30+
queryFn: () => fetchIrisDept(meta!.base, dept),
31+
enabled: !!meta,
32+
staleTime: Infinity,
33+
})),
34+
combine: (results) => {
35+
const features: IrisFeature[] = []
36+
const loadedDepts = new Set<string>()
37+
results.forEach((r, i) => {
38+
if (r.data) {
39+
features.push(...r.data.features)
40+
loadedDepts.add(capped[i])
41+
}
42+
})
43+
return {
44+
features,
45+
loadedDepts,
46+
isFetching: results.some((r) => r.isFetching),
47+
}
48+
},
49+
})
50+
}

src/lib/colorScale.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { makeColorScale } from "@/lib/colorScale"
4+
import { NO_DATA } from "@/lib/palettes"
5+
import type { ChoroplethFeature, ChoroplethProperties } from "@/lib/choropleth"
6+
7+
// Fixture minimale : makeColorScale ne lit que properties via les accessors.
8+
const feature = (prix: number | null, fiable = true) =>
9+
({
10+
type: "Feature",
11+
geometry: null,
12+
properties: { prix_m2_median: prix, fiable },
13+
}) as unknown as ChoroplethFeature
14+
15+
const getValue = (p: ChoroplethProperties) => p.prix_m2_median
16+
const getFiable = (p: ChoroplethProperties) => p.fiable
17+
18+
describe("makeColorScale().color (value-based, couche IRIS)", () => {
19+
const features = [1000, 2000, 3000, 4000, 5000, 6000, null].map((v) =>
20+
feature(v),
21+
)
22+
const scale = makeColorScale(features, getValue, getFiable)
23+
24+
it("rend NO_DATA pour null", () => {
25+
expect(scale.color(null)).toEqual(NO_DATA)
26+
expect(scale.color(undefined)).toEqual(NO_DATA)
27+
})
28+
29+
it("atténue l'alpha à 110 quand fiable=false", () => {
30+
const plein = scale.color(3000)
31+
const attenue = scale.color(3000, false)
32+
expect(attenue.slice(0, 3)).toEqual(plein.slice(0, 3))
33+
expect(attenue[3]).toBe(110)
34+
})
35+
36+
it("reste cohérente avec getColor (mêmes seuils commune/quartier)", () => {
37+
for (const f of [feature(1500), feature(5500), feature(null), feature(2500, false)]) {
38+
expect(scale.color(getValue(f.properties), getFiable(f.properties))).toEqual(
39+
scale.getColor(f),
40+
)
41+
}
42+
})
43+
})

src/lib/colorScale.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export function quantileScale(
4444

4545
export interface FeatureColorScale {
4646
getColor(f: ChoroplethFeature): RGBA
47+
/** Couleur d'une valeur brute — mêmes seuils que getColor (couche IRIS :
48+
* un quartier se lit sur l'échelle calculée sur les communes). */
49+
color(v: number | null | undefined, fiable?: boolean): RGBA
4750
/** Bornes de classes (pour la légende), croissantes. */
4851
thresholds: number[]
4952
palette: RGBA[]
@@ -62,14 +65,16 @@ export function makeColorScale(
6265
const values = features.map((f) => getValue(f.properties))
6366
const thresholds = quantileThresholds(values, PRICE_SEQ.length)
6467
const base = quantileScale(values, PRICE_SEQ)
68+
const color = (v: number | null | undefined, fiable = true): RGBA => {
69+
if (v == null) return NO_DATA
70+
const rgba = base(v)
71+
return fiable ? rgba : [rgba[0], rgba[1], rgba[2], 110]
72+
}
6573
return {
6674
thresholds,
6775
palette: PRICE_SEQ,
68-
getColor(feature: ChoroplethFeature): RGBA {
69-
const v = getValue(feature.properties)
70-
if (v == null) return NO_DATA
71-
const rgba = base(v)
72-
return getFiable(feature.properties) ? rgba : [rgba[0], rgba[1], rgba[2], 110]
73-
},
76+
color,
77+
getColor: (feature: ChoroplethFeature): RGBA =>
78+
color(getValue(feature.properties), getFiable(feature.properties)),
7479
}
7580
}

src/lib/data.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export const metaSchema = z.object({
2020
// Communes couvertes par l'analyse d'avis (0 ou absent = pas d'avis dans ce
2121
// run : les sections Avis se masquent d'elles-mêmes).
2222
nb_communes_avis: z.number().optional(),
23+
// Maille quartier IRIS (0 ou absent = run sans quartiers : la couche carte
24+
// reste masquée).
25+
nb_iris: z.number().optional(),
26+
nb_iris_scores: z.number().optional(),
2327
generated_at: z.string(),
2428
})
2529
export type Meta = z.infer<typeof metaSchema>

0 commit comments

Comments
 (0)