feat(carte): carte SVG legere des 4 regions sur accueil et bilans defi (ADR-002, 92 KB gz, SSR sans MapLibre)

This commit is contained in:
sucupira
2026-07-16 23:21:31 -04:00
parent 3f4f284c0a
commit ce04a56f1e
9 changed files with 3403 additions and 47 deletions
+339
View File
@@ -0,0 +1,339 @@
<script lang="ts" module>
import geoJson from '$lib/map/regions-geo.json';
import type { Region } from '$lib/types';
/** Point à afficher sur la carte (bilan du défi). */
export interface MarqueurCarte {
lat: number;
lon: number;
type: 'reel' | 'guess';
label?: string;
/** Numéro de manche : relie le guess au réel de même paire. */
paire?: number;
}
export type SelectionCarte = Region | 'TOUTES' | null;
type Anneau = [number, number][];
interface RegionGeo {
nom: string;
creole: string;
polygons: Anneau[];
}
const GEO = geoJson as unknown as Record<Region, RegionGeo>;
/**
* Carte SVG statique des 4 régions, écrite from scratch pour JWE (ADR-002) :
* projection équirectangulaire simple par région, chaque région dans son
* propre inset (les 4 régions sont à ~12 000 km d'écart — la disposition
* 2×2 est un choix cartographique assumé, sans prétention d'échelle commune).
* Tout est précalculé au chargement du module : rendu SSR sans aucun JS client.
*/
const VUE_L = 600; // largeur du viewBox
const CELLULE_L = VUE_L / 2; // 2 colonnes
const CELLULE_H = 310; // 2 lignes
const MARGE = 14; // respiration autour des formes dans un inset
const HAUTEUR_NOM = 26; // bandeau du nom de région en bas d'inset
/** Ordre des insets : Antilles en haut, Guyane et Réunion en bas. */
const ORDRE: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
interface Inset {
region: Region;
nom: string;
creole: string;
colonne: number;
rang: number;
/** Chemins SVG (attributs `d`) des communes, déjà projetés dans le viewBox. */
chemins: string[];
/** Position du libellé de région. */
nomX: number;
nomY: number;
/** Bornes lon/lat brutes — sert à affecter un marqueur à son inset. */
bornes: [number, number, number, number];
/** Projection lon/lat → coordonnées du viewBox. */
projeter: (lon: number, lat: number) => [number, number];
}
const r1 = (n: number) => Math.round(n * 10) / 10;
function construireInsets(): Inset[] {
return ORDRE.map((region, i) => {
const { nom, creole, polygons } = GEO[region];
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const poly of polygons) {
for (const [lon, lat] of poly) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
}
// Équirectangulaire : un degré de longitude vaut cos(lat) × un degré de latitude.
const cos0 = Math.cos(((minLat + maxLat) / 2) * (Math.PI / 180));
const largeurGeo = (maxLon - minLon) * cos0;
const hauteurGeo = maxLat - minLat;
const colonne = i % 2;
const rang = Math.floor(i / 2);
const dispoL = CELLULE_L - 2 * MARGE;
const dispoH = CELLULE_H - 2 * MARGE - HAUTEUR_NOM;
const echelle = Math.min(dispoL / largeurGeo, dispoH / hauteurGeo);
// Centre les formes dans l'espace disponible de l'inset.
const origX = colonne * CELLULE_L + MARGE + (dispoL - largeurGeo * echelle) / 2;
const origY = rang * CELLULE_H + MARGE + (dispoH - hauteurGeo * echelle) / 2;
const projeter = (lon: number, lat: number): [number, number] => [
r1(origX + (lon - minLon) * cos0 * echelle),
r1(origY + (maxLat - lat) * echelle)
];
const chemins = polygons.map(
(poly) =>
'M' + poly.map(([lon, lat]) => projeter(lon, lat).join(',')).join('L') + 'Z'
);
return {
region,
nom,
creole,
colonne,
rang,
chemins,
nomX: colonne * CELLULE_L + CELLULE_L / 2,
nomY: (rang + 1) * CELLULE_H - 7,
bornes: [minLon, minLat, maxLon, maxLat],
projeter
};
});
}
const INSETS = construireInsets();
const VUE_H = CELLULE_H * 2;
/** Projette un point dans l'inset de sa région ; null si hors de toute région (omis). */
function projeterMarqueur(lon: number, lat: number): [number, number] | null {
for (const ins of INSETS) {
const [minLon, minLat, maxLon, maxLat] = ins.bornes;
if (lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat) {
return ins.projeter(lon, lat);
}
}
return null;
}
</script>
<script lang="ts">
import { t } from '$lib/i18n/store.svelte';
interface Props {
/** Région sélectionnée (liaison bidirectionnelle) — ignorée si `selectionnable` est faux. */
selection?: SelectionCarte;
/** Vrai (défaut) : chaque région est un vrai <button> ; faux : carte de récap statique. */
selectionnable?: boolean;
marqueurs?: MarqueurCarte[];
attribution?: string;
}
let {
selection = $bindable<SelectionCarte>(null),
selectionnable = true,
marqueurs = [],
attribution = '© contributeurs OpenStreetMap / données publiques'
}: Props = $props();
let survol = $state<Region | null>(null);
interface MarqueurProjete extends MarqueurCarte {
x: number;
y: number;
cle: string;
}
// Points hors de tout inset : omis silencieusement (consigne ADR-002).
const marqueursProjetes = $derived(
marqueurs.flatMap((m, i): MarqueurProjete[] => {
const pt = projeterMarqueur(m.lon, m.lat);
return pt ? [{ ...m, x: pt[0], y: pt[1], cle: `${i}-${m.type}` }] : [];
})
);
// Ligne guess → réel pour chaque paire complète (même numéro de manche).
const lignes = $derived.by(() => {
const paires = new Map<number, Partial<Record<'guess' | 'reel', [number, number]>>>();
for (const m of marqueursProjetes) {
if (m.paire == null) continue;
const entree = paires.get(m.paire) ?? {};
entree[m.type] = [m.x, m.y];
paires.set(m.paire, entree);
}
const out: { paire: number; x1: number; y1: number; x2: number; y2: number }[] = [];
for (const [paire, e] of paires) {
if (e.guess && e.reel) {
out.push({ paire, x1: e.guess[0], y1: e.guess[1], x2: e.reel[0], y2: e.reel[1] });
}
}
return out;
});
</script>
<figure class="carte-regions">
<div class="cadre">
<svg
viewBox="0 0 {VUE_L} {VUE_H}"
role={selectionnable ? undefined : 'img'}
aria-hidden={selectionnable ? 'true' : undefined}
aria-label={selectionnable ? undefined : t().carte.carteRegionsLabel}
>
{#each INSETS as ins (ins.region)}
<g
class="region"
class:survolee={selectionnable && survol === ins.region}
class:choisie={selectionnable && selection === ins.region}
>
{#each ins.chemins as d (d)}
<path {d} />
{/each}
</g>
{/each}
{#each lignes as l (l.paire)}
<line class="lien-paire" x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} />
{/each}
{#each marqueursProjetes as m (m.cle)}
<g class="marqueur {m.type}">
{#if m.label}<title>{m.label}</title>{/if}
<circle cx={m.x} cy={m.y} r="8" />
{#if m.paire != null}
<text x={m.x} y={m.y + 3.5}>{m.paire}</text>
{/if}
</g>
{/each}
{#each INSETS as ins (ins.region)}
<text
class="nom-region"
class:choisie={selectionnable && selection === ins.region}
x={ins.nomX}
y={ins.nomY}>{t().regions[ins.region]}</text
>
{/each}
</svg>
{#if selectionnable}
<!-- Un vrai <button> par région, calé sur son inset : clavier + lecteur d'écran.
Le nom accessible est bilingue (FR / créole), le libellé visible suit la langue. -->
{#each INSETS as ins (ins.region)}
<button
type="button"
class="zone-region"
style="left: {ins.colonne * 50}%; top: {ins.rang * 50}%"
aria-pressed={selection === ins.region}
aria-label="{ins.nom} / {ins.creole}"
onclick={() => (selection = ins.region)}
onmouseenter={() => (survol = ins.region)}
onmouseleave={() => (survol = null)}
onfocus={() => (survol = ins.region)}
onblur={() => (survol = null)}
></button>
{/each}
{/if}
</div>
<figcaption class="attribution">{attribution}</figcaption>
</figure>
<style>
.carte-regions {
margin: 0;
width: 100%;
}
.cadre {
position: relative;
width: 100%;
}
svg {
display: block;
width: 100%;
height: auto;
}
/* — Régions : tokens OKI, aucun bleu « Google » — */
.region path {
fill: var(--oki-vert-doux);
stroke: var(--oki-vert-clair);
stroke-width: 0.9;
}
.region.survolee path {
fill: var(--oki-bleu-doux);
stroke: var(--oki-bleu);
}
.region.choisie path {
fill: var(--oki-vert-clair);
stroke: var(--oki-vert-doux);
}
.nom-region {
fill: var(--oki-vert);
font-size: 15px;
font-weight: 700;
text-anchor: middle;
}
.nom-region.choisie {
text-decoration: underline;
}
/* — Marqueurs du récap (guess = corail, réel = vert, comme la légende du bilan) — */
.lien-paire {
stroke: var(--oki-encre-doux);
stroke-width: 2;
stroke-dasharray: 6 4;
fill: none;
}
.marqueur circle {
stroke: var(--oki-blanc);
stroke-width: 2;
}
.marqueur.guess circle {
fill: var(--oki-corail);
}
.marqueur.reel circle {
fill: var(--oki-vert);
}
.marqueur text {
fill: #fff;
font-size: 10px;
font-weight: 800;
text-anchor: middle;
pointer-events: none;
}
/* Boutons transparents calés sur les insets (2×2) */
.zone-region {
position: absolute;
width: 50%;
height: 50%;
padding: 0;
border: none;
background: transparent;
border-radius: var(--oki-radius);
cursor: pointer;
}
.zone-region:focus-visible {
outline: 3px solid var(--oki-bleu);
outline-offset: -3px;
}
.attribution {
margin: 0.3rem 0 0;
font-size: 0.72rem;
color: var(--oki-encre-doux);
text-align: right;
}
</style>