Skip to content

Commit e5355da

Browse files
authored
Merge pull request #41 from T-DAT-902-Homepedia/feat/carte-basemap-picker
feat(webapp): basemap picker, landing copy fixes and tricolor brand accent
2 parents 7fe780c + c276d97 commit e5355da

11 files changed

Lines changed: 277 additions & 115 deletions

File tree

docker/dev/Dockerfile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,11 @@ WORKDIR /app
2222

2323
EXPOSE 5173
2424

25-
# Symlink node_modules dans /app après le bind mount, puis lancer vite
26-
CMD ["sh", "-c", "ln -sf /deps/node_modules /app/node_modules && npm run dev -- --host 0.0.0.0"]
25+
# Symlink node_modules dans /app après le bind mount, puis lancer vite.
26+
# UNIQUEMENT si absent : quand l'hôte fournit déjà node_modules via le bind
27+
# mount, `ln -sf` créait node_modules/node_modules -> /deps/node_modules, que
28+
# la résolution Node (remontée de dossiers) emprunte depuis l'intérieur de
29+
# node_modules — react-dom résolvait react dans /deps pendant que l'app le
30+
# résolvait dans /app : deux copies de React dans le bundle de l'optimiseur
31+
# Vite, « Invalid hook call » sur toutes les pages au premier re-bundle.
32+
CMD ["sh", "-c", "[ -e /app/node_modules ] || ln -s /deps/node_modules /app/node_modules; npm run dev -- --host 0.0.0.0"]

src/components/france-outline.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,19 @@ const FRANCE_PATH =
1212
// Positions viewBox des grandes villes, mêmes projection et normalisation que
1313
// FRANCE_PATH. Délais irréguliers pour éviter un clignotement synchrone.
1414
// pop = population communale (INSEE, ordre de grandeur 2021).
15+
// `red` : accent tricolore de marque sur 3 points répartis sans motif
16+
// géographique lisible (Paris, le plus gros, garde la couleur dominante).
1517
const CITY_DOTS = [
1618
{ name: "Paris", x: 498, y: 226, delay: "0s", pop: 2_133_000 },
1719
{ name: "Lyon", x: 671, y: 539, delay: "1.3s", pop: 522_000 },
1820
{ name: "Marseille", x: 708, y: 789, delay: "0.5s", pop: 870_000 },
1921
{ name: "Toulouse", x: 435, y: 757, delay: "1.9s", pop: 504_000 },
20-
{ name: "Bordeaux", x: 294, y: 633, delay: "0.9s", pop: 260_000 },
22+
{ name: "Bordeaux", x: 294, y: 633, delay: "0.9s", pop: 260_000, red: true },
2123
{ name: "Nantes", x: 226, y: 392, delay: "2.3s", pop: 320_000 },
22-
{ name: "Lille", x: 547, y: 47, delay: "1.6s", pop: 236_000 },
24+
{ name: "Lille", x: 547, y: 47, delay: "1.6s", pop: 236_000, red: true },
2325
{ name: "Strasbourg", x: 874, y: 255, delay: "0.7s", pop: 290_000 },
2426
{ name: "Rennes", x: 217, y: 301, delay: "1.1s", pop: 225_000 },
25-
{ name: "Nice", x: 840, y: 747, delay: "2.6s", pop: 348_000 },
27+
{ name: "Nice", x: 840, y: 747, delay: "2.6s", pop: 348_000, red: true },
2628
{ name: "Ajaccio", x: 943, y: 928, delay: "2.1s", pop: 73_000 },
2729
]
2830

@@ -53,7 +55,7 @@ export function FranceOutline(props: SVGProps<SVGSVGElement>) {
5355
vectorEffect="non-scaling-stroke"
5456
/>
5557
{CITY_DOTS.map((city) => (
56-
<g key={city.name}>
58+
<g key={city.name} className={city.red ? "text-brand-red" : undefined}>
5759
<circle
5860
cx={city.x}
5961
cy={city.y}

src/components/map-top-bar.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate } from "react-router-dom"
33

44
import { SearchCommand, SearchTrigger } from "@/components/search-command"
55
import { ThemeToggle } from "@/components/theme-toggle"
6+
import { TricolorMark } from "@/components/tricolor-mark"
67
import { useSearchShortcut } from "@/hooks/useSearchShortcut"
78
import { NAV } from "@/lib/nav"
89
import type { SearchEntry } from "@/lib/search"
@@ -33,9 +34,12 @@ export function MapTopBar({
3334
<div className="flex items-center gap-2 px-3 py-2 sm:gap-4 sm:px-4">
3435
<Link
3536
to="/"
36-
className="font-display text-base font-bold tracking-tight sm:text-lg"
37+
className="inline-flex items-center gap-1.5 font-display text-base font-bold tracking-tight sm:text-lg"
3738
>
38-
Homepedia<span className="text-accent">.</span>
39+
<TricolorMark />
40+
<span>
41+
Homepedia<span className="text-accent">.</span>
42+
</span>
3943
</Link>
4044
<nav className="flex items-center gap-0.5 overflow-x-auto text-sm">
4145
{NAV.map((item) => (

src/components/page-shell.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Menu, X } from "lucide-react"
44

55
import { SearchCommand, SearchTrigger } from "@/components/search-command"
66
import { ThemeToggle } from "@/components/theme-toggle"
7+
import { TricolorMark } from "@/components/tricolor-mark"
78
import { Button } from "@/components/ui/button"
89
import { useSearchShortcut } from "@/hooks/useSearchShortcut"
910
import { NAV } from "@/lib/nav"
@@ -59,8 +60,14 @@ export function PageShell({
5960
>
6061
{menuOpen ? <X className="size-4" /> : <Menu className="size-4" />}
6162
</Button>
62-
<Link to="/" className="font-display text-lg font-bold tracking-tight">
63-
Homepedia<span className="text-accent">.</span>
63+
<Link
64+
to="/"
65+
className="inline-flex items-center gap-2 font-display text-lg font-bold tracking-tight"
66+
>
67+
<TricolorMark />
68+
<span>
69+
Homepedia<span className="text-accent">.</span>
70+
</span>
6471
</Link>
6572
<nav className="hidden items-center gap-1 text-sm md:flex">
6673
{NAV.map((item) => navLink(item))}

src/components/score-sidebar.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DivergingLegend,
99
QuantileLegend,
1010
} from "@/components/map-legend"
11+
import { BASEMAP_KEYS, BASEMAP_LABELS, type Basemap } from "@/lib/basemaps"
1112
import { cn } from "@/lib/utils"
1213
import {
1314
DIMENSIONS,
@@ -18,15 +19,6 @@ import {
1819
} from "@/lib/score"
1920
import type { makeBivariateScale, MetricScale } from "@/lib/scoreColors"
2021

21-
export type Basemap = "clair" | "sombre" | "satellite" | "couleur"
22-
export const BASEMAP_LABELS: Record<Basemap, string> = {
23-
clair: "Clair",
24-
sombre: "Sombre",
25-
satellite: "Satellite",
26-
couleur: "Couleur",
27-
}
28-
const BASEMAP_KEYS = Object.keys(BASEMAP_LABELS) as Basemap[]
29-
3022
export type MapView = { center: [number, number]; zoom: number }
3123

3224
// Points de recentrage : France métropolitaine + les 5 DROM (éloignés, non

src/components/tricolor-mark.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { cn } from "@/lib/utils"
2+
3+
/**
4+
* Bloc tricolore de la marque : trois bandes verticales bleu/blanc/rouge
5+
* cerclées d'une hairline (sans elle, la bande blanche disparaît sur le fond
6+
* paper). Dimensionné en em : suit le corps du texte parent, pas de prop de
7+
* taille. Purement décoratif — le rouge est ici un accent de marque, jamais
8+
* un état ni une donnée (cf. doctrine --brand-red, index.css).
9+
*/
10+
export function TricolorMark({ className }: { className?: string }) {
11+
return (
12+
<span
13+
aria-hidden
14+
className={cn(
15+
"inline-flex shrink-0 self-center overflow-hidden rounded-[0.15em] ring-1 ring-border",
16+
className,
17+
)}
18+
>
19+
<span className="h-[0.65em] w-[0.28em] bg-accent" />
20+
<span className="h-[0.65em] w-[0.28em] bg-white dark:bg-paper" />
21+
<span className="h-[0.65em] w-[0.28em] bg-brand-red" />
22+
</span>
23+
)
24+
}

src/index.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
--destructive: oklch(0.62 0.235 25);
3333
--destructive-foreground: oklch(1 0 0);
3434

35+
/* Rouge tricolore : accent de MARQUE décoratif (logo, hero landing) —
36+
jamais un état interactif ni une donnée (le rouge YlOrRd des cartes
37+
signifie « cher », --destructive signifie erreur/baisse). Carmin
38+
(teinte 20) pour rester distinct du rouge erreur (teinte 25). */
39+
--brand-red: oklch(0.50 0.21 20);
40+
3541
--border: oklch(0.91 0.008 80);
3642
--input: oklch(0.91 0.008 80);
3743
--ring: oklch(0.47 0.245 265);
@@ -77,6 +83,9 @@
7783
--destructive: oklch(0.67 0.22 25);
7884
--destructive-foreground: oklch(1 0 0);
7985

86+
/* Éclairci pour rester lisible (AA) sur le bleu nuit. */
87+
--brand-red: oklch(0.63 0.20 18);
88+
8089
--border: oklch(1 0 0 / 10%);
8190
--input: oklch(1 0 0 / 15%);
8291
--ring: oklch(0.47 0.245 265);
@@ -142,6 +151,7 @@
142151
--color-ink: oklch(0.10 0.015 265);
143152
--color-paper: oklch(0.966 0.008 80);
144153
--color-blue: oklch(0.47 0.245 265);
154+
--color-brand-red: var(--brand-red);
145155
--color-blue-light: oklch(0.96 0.022 265);
146156
--color-green: oklch(0.74 0.16 165);
147157
--color-warm: oklch(0.73 0.165 55);

src/lib/basemaps.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Source UNIQUE des fonds de carte : /map et /carte proposent les mêmes fonds
2+
// avec les mêmes clés (le paramètre d'URL `fond=` circule entre les deux pages
3+
// via les liens croisés).
4+
//
5+
// - Vecteur Carto (schéma OpenMapTiles) : clair (Positron), sombre (Dark
6+
// Matter), couleur (Voyager) — labels repassés en FR par syncBasemapStyle.
7+
// - Satellite : imagerie aérienne Esri (raster, sans clé ni labels).
8+
9+
import type { Map as MaplibreMap, StyleSpecification } from "maplibre-gl"
10+
11+
export type Basemap = "clair" | "sombre" | "satellite" | "couleur"
12+
13+
export const BASEMAP_LABELS: Record<Basemap, string> = {
14+
clair: "Clair",
15+
sombre: "Sombre",
16+
satellite: "Satellite",
17+
couleur: "Couleur",
18+
}
19+
20+
export const BASEMAP_KEYS = Object.keys(BASEMAP_LABELS) as Basemap[]
21+
22+
export const isBasemap = (v: string | null): v is Basemap =>
23+
v != null && v in BASEMAP_LABELS
24+
25+
// Imagerie aérienne Esri (raster, sans clé) pour le fond « Satellite ».
26+
const SATELLITE_STYLE: StyleSpecification = {
27+
version: 8,
28+
glyphs:
29+
"https://basemaps.cartocdn.com/gl/positron-gl-style/{fontstack}/{range}.pbf",
30+
sources: {
31+
"esri-imagery": {
32+
type: "raster",
33+
tiles: [
34+
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
35+
],
36+
tileSize: 256,
37+
attribution:
38+
"Tuiles © Esri — Source : Esri, Maxar, Earthstar Geographics, GIS User Community",
39+
},
40+
},
41+
layers: [{ id: "esri-imagery", type: "raster", source: "esri-imagery" }],
42+
}
43+
44+
export const BASEMAP_STYLES: Record<Basemap, string | StyleSpecification> = {
45+
clair: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
46+
sombre: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
47+
couleur: "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
48+
satellite: SATELLITE_STYLE,
49+
}
50+
51+
// Libellés en français : on remplace le champ texte des calques de symboles par
52+
// le nom FR de la tuile vectorielle (fallback latin puis nom par défaut).
53+
const FR_LABEL = [
54+
"coalesce",
55+
["get", "name:fr"],
56+
["get", "name:latin"],
57+
["get", "name"],
58+
]
59+
60+
/** À appeler sur chaque styledata — chargement initial OU changement de fond :
61+
* repasse les labels en français et renvoie l'id du 1er calque de symboles
62+
* (le beforeId sous lequel glisser les couches deck pour garder les labels
63+
* au-dessus). Le satellite (raster, sans symbole) court-circuite : pas de FR,
64+
* renvoie undefined (couches au-dessus du fond). */
65+
export function syncBasemapStyle(map: MaplibreMap): string | undefined {
66+
const styleLayers = map.getStyle()?.layers ?? []
67+
const symbols = styleLayers.filter((l) => l.type === "symbol")
68+
if (symbols.length === 0) return undefined
69+
const fr = JSON.stringify(FR_LABEL)
70+
// Idempotent : on ne réécrit que si un libellé n'est pas déjà en FR, sinon
71+
// nos setLayoutProperty re-déclencheraient styledata en boucle.
72+
const needsFr = symbols.some((l) => {
73+
const tf = map.getLayoutProperty(l.id, "text-field")
74+
return tf != null && JSON.stringify(tf) !== fr
75+
})
76+
if (needsFr) {
77+
for (const layer of symbols) {
78+
if (map.getLayoutProperty(layer.id, "text-field") == null) continue
79+
map.setLayoutProperty(layer.id, "text-field", FR_LABEL)
80+
}
81+
}
82+
return symbols[0]?.id
83+
}

src/pages/dvf-map.tsx

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom"
1111
import { Button } from "@/components/ui/button"
1212
import { DeckOverlay } from "@/components/deck-overlay"
1313
import { MapTopBar } from "@/components/map-top-bar"
14+
import {
15+
BASEMAP_KEYS,
16+
BASEMAP_LABELS,
17+
BASEMAP_STYLES,
18+
isBasemap,
19+
syncBasemapStyle,
20+
type Basemap,
21+
} from "@/lib/basemaps"
1422
import { useChoropleth } from "@/hooks/useChoropleth"
1523
import {
1624
HIGH_ZOOM_THRESHOLD,
@@ -72,7 +80,7 @@ const POINTS_ZOOM = 11
7280
const HEAT_PRICE_DOMAIN: [number, number] = [1500, 6000]
7381

7482
// --- État dans l'URL (partage/refresh, audit N4) ----------------------------
75-
// v=lat,lng,zoom ; repr= ; type= ; poids= ; iso=1
83+
// v=lat,lng,zoom ; repr= ; type= ; poids= ; iso=1 ; fond= (commun avec /map)
7684

7785
function parseViewParam(v: string | null): MapViewState | null {
7886
if (!v) return null
@@ -119,6 +127,10 @@ export default function DvfMap() {
119127
params.get("poids") === "ventes" ? "ventes" : "prix",
120128
)
121129
const [contours, setContours] = useState(() => params.get("iso") === "1")
130+
const [basemap, setBasemap] = useState<Basemap>(() => {
131+
const f = params.get("fond")
132+
return isBasemap(f) ? f : "clair"
133+
})
122134

123135
// Filtres du store initialisés depuis l'URL (une fois, au montage).
124136
useEffect(() => {
@@ -151,8 +163,9 @@ export default function DvfMap() {
151163
if (heatWeight !== "prix") next.set("poids", heatWeight)
152164
if (contours) next.set("iso", "1")
153165
}
166+
if (basemap !== "clair") next.set("fond", basemap)
154167
setParams(next, { replace: true })
155-
}, [debouncedView, representation, typeLocal, heatWeight, contours, setParams])
168+
}, [debouncedView, representation, typeLocal, heatWeight, contours, basemap, setParams])
156169

157170
// Dézoom manuel : le fil d'Ariane se tronque au niveau réellement visible.
158171
useEffect(() => {
@@ -452,12 +465,16 @@ export default function DvfMap() {
452465
fillBeforeId,
453466
])
454467

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)
468+
// À chaque (re)chargement de style — initial OU changement de fond — labels FR
469+
// + mémorisation du 1er calque de symboles (beforeId), cf. lib/basemaps.
470+
const onStyleData = (map: MaplibreMap) => setFillBeforeId(syncBasemapStyle(map))
471+
472+
// Changement de fond : détacher le beforeId dans le même commit — l'ancien id
473+
// n'existe pas (encore) dans le style suivant, deck ré-ajouterait les couches
474+
// sous un calque fantôme ; le styledata du nouveau style le re-fournit.
475+
const changeBasemap = (b: Basemap) => {
476+
setFillBeforeId(undefined)
477+
setBasemap(b)
461478
}
462479

463480
// Remplace le getCursor de DeckGL : pointer au survol d'une entité pickable,
@@ -476,7 +493,9 @@ export default function DvfMap() {
476493
<MapTopBar
477494
extra={
478495
<Button variant="ghost" size="sm" asChild className="max-md:hidden">
479-
<Link to={`/map?v=${viewParam(debouncedView)}`}>
496+
<Link
497+
to={`/map?v=${viewParam(debouncedView)}${basemap !== "clair" ? `&fond=${basemap}` : ""}`}
498+
>
480499
Voir en qualité de vie
481500
</Link>
482501
</Button>
@@ -485,7 +504,10 @@ export default function DvfMap() {
485504
<Map
486505
ref={mapRef}
487506
initialViewState={initialView}
488-
mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
507+
mapStyle={BASEMAP_STYLES[basemap]}
508+
// Rechargement complet au changement de fond (les styles Carto partagent
509+
// les mêmes ids ; le diff laisserait des libellés anglais résiduels).
510+
styleDiffing={false}
489511
onMove={(e) => setViewState(e.viewState)}
490512
onStyleData={(e) => onStyleData(e.target)}
491513
style={{ width: "100%", height: "100%" }}
@@ -594,6 +616,25 @@ export default function DvfMap() {
594616
</div>
595617
)}
596618

619+
{/* Fond de carte (fond= dans l'URL, commun avec /map). */}
620+
<div className="mt-2 flex flex-wrap items-center gap-1.5 border-t pt-2 text-xs">
621+
<span className="text-muted-foreground">Fond</span>
622+
{BASEMAP_KEYS.map((b) => (
623+
<button
624+
key={b}
625+
type="button"
626+
onClick={() => changeBasemap(b)}
627+
className={
628+
b === basemap
629+
? "rounded border border-accent bg-accent/10 px-2 py-0.5 font-semibold text-accent"
630+
: "rounded border border-input px-2 py-0.5 text-muted-foreground hover:bg-muted"
631+
}
632+
>
633+
{BASEMAP_LABELS[b]}
634+
</button>
635+
))}
636+
</div>
637+
597638
{!heat && mesh !== "communes" && (
598639
<p className="mt-2 max-w-52 text-xs text-muted-foreground">
599640
Cliquez sur une {mesh === "regions" ? "région" : "département"} pour

0 commit comments

Comments
 (0)