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:
@@ -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>
|
||||
Reference in New Issue
Block a user