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
+2864
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^26.1.1",
"mapshaper": "^0.7.45",
"svelte": "^5.0.0",
"svelte-check": "^4.1.0",
"typescript": "^5.6.0",
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
/**
* Génère `src/lib/map/regions-geo.json` : contours simplifiés des communes des
* 4 régions JWE, pour la carte SVG statique des écrans hors-jeu (ADR-002).
*
* Pipeline : geo.api.gouv.fr (contours communes, gratuit, sans clé)
* → simplification mapshaper (Visvalingam, `keep-shapes`)
* → anneaux externes uniquement, MultiPolygons aplatis, coords à 5 décimales
* → JSON minifié versionné dans le repo.
*
* Rejouable : `node scripts/build-carte-regions.mjs [pourcentage]`
* pourcentage = seuil mapshaper (défaut 7%, calibré : ~90 KB gzip -9).
* Le script affiche le poids brut et gzip -9 (budget ADR-002 V1 : ≤ 150 KB gzippé).
*/
import { writeFile } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mapshaper from 'mapshaper';
const REGIONS = {
'01': { cle: 'GUADELOUPE', nom: 'Guadeloupe', creole: 'Gwadloup' },
'02': { cle: 'MARTINIQUE', nom: 'Martinique', creole: 'Matinik' },
'03': { cle: 'GUYANE', nom: 'Guyane', creole: 'Giyan' },
'04': { cle: 'REUNION', nom: 'La Réunion', creole: 'Léryinyon' }
};
const USER_AGENT = 'JWE-OKI/2.0 (https://labola.o-k-i.net ; scripts/build-carte-regions)';
const BUDGET_GZ = 150 * 1024;
const SORTIE = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'src',
'lib',
'map',
'regions-geo.json'
);
const pourcentage = process.argv[2] ?? '7%';
async function fetchCommunes(codeRegion) {
const url = `https://geo.api.gouv.fr/communes?codeRegion=${codeRegion}&fields=nom,code,contour&format=json&geometry=contour`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }
});
if (!res.ok) throw new Error(`geo.api.gouv.fr région ${codeRegion} : HTTP ${res.status}`);
return res.json();
}
function applyCommands(cmd, inputs) {
return new Promise((resolve, reject) => {
mapshaper.applyCommands(cmd, inputs, (err, out) => (err ? reject(err) : resolve(out)));
});
}
/** Simplifie un GeoJSON avec mapshaper et renvoie le GeoJSON parsé. */
async function simplifier(featureCollection) {
const cmd = `-i communes.geojson -simplify ${pourcentage} keep-shapes -o sortie.geojson`;
const out = await applyCommands(cmd, { 'communes.geojson': featureCollection });
const brut = out['sortie.geojson'];
// mapshaper renvoie un Buffer en sortie fichier (Node), une string ou un objet selon le format
if (Buffer.isBuffer(brut)) return JSON.parse(brut.toString('utf8'));
return typeof brut === 'string' ? JSON.parse(brut) : brut;
}
const r5 = (n) => Math.round(n * 1e5) / 1e5;
/** Anneaux externes seuls ; MultiPolygon → plusieurs polygones ; coords arrondies. */
function extrairePolygones(geojson) {
const polygons = [];
for (const feature of geojson.features) {
const g = feature.geometry;
const multi = g?.type === 'MultiPolygon' ? g.coordinates : g?.type === 'Polygon' ? [g.coordinates] : [];
for (const poly of multi) {
const anneau = poly[0];
if (!Array.isArray(anneau) || anneau.length < 4) continue;
polygons.push(anneau.map(([lon, lat]) => [r5(lon), r5(lat)]));
}
}
return polygons;
}
const resultat = {};
for (const [codeRegion, info] of Object.entries(REGIONS)) {
const communes = await fetchCommunes(codeRegion);
const fc = {
type: 'FeatureCollection',
features: communes
.filter((c) => c.contour)
.map((c) => ({
type: 'Feature',
properties: { code: c.code, nom: c.nom },
geometry: c.contour
}))
};
const simplifie = await simplifier(fc);
const polygons = extrairePolygones(simplifie);
const points = polygons.reduce((s, p) => s + p.length, 0);
resultat[info.cle] = { nom: info.nom, creole: info.creole, polygons };
console.log(
`${info.cle.padEnd(11)} ${String(fc.features.length).padStart(3)} communes → ` +
`${String(polygons.length).padStart(3)} polygones, ${points} points`
);
}
const json = JSON.stringify(resultat);
await writeFile(SORTIE, json + '\n', 'utf8');
const brut = Buffer.byteLength(json, 'utf8');
const gz = gzipSync(json, { level: 9 }).length;
console.log(`\n${path.relative(process.cwd(), SORTIE)}`);
console.log(`Brut : ${(brut / 1024).toFixed(1)} KB`);
console.log(`gzip -9 : ${(gz / 1024).toFixed(1)} KB (budget V1 : 150 KB)`);
if (gz > BUDGET_GZ) {
console.error('DÉPASSEMENT DU BUDGET — relancer avec un pourcentage plus faible.');
process.exitCode = 1;
}
+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>
+8 -3
View File
@@ -25,7 +25,8 @@ const fr = {
choixMode: 'Choisis ton mode',
options: 'Options',
timer: 'Minuteur (60 s, bonus de score)',
jouer: 'Jouer'
jouer: 'Jouer',
creerLienDefi: 'Créer un lien de défi'
},
jeu: {
charger: 'Chargement du lieu…',
@@ -87,7 +88,10 @@ const fr = {
telecharger: 'Télécharger limage',
taPosition: 'Ta position',
positionReelle: 'Position réelle',
manche: 'Manche'
manche: 'Manche',
titreSeed: 'Défi — seed',
copierLien: 'Copier le lien du défi',
lienCopie: 'Lien copié !'
},
explorer: {
titre: 'Aprann — Explorer',
@@ -100,7 +104,8 @@ const fr = {
ariaLabel: 'Carte de jeu. Flèches pour déplacer le marqueur, Entrée pour valider.',
sautRegion: 'Aller à la région',
zoomIn: 'Zoomer',
zoomOut: 'Dézoomer'
zoomOut: 'Dézoomer',
carteRegionsLabel: 'Carte des quatre régions : Guadeloupe, Martinique, Guyane, La Réunion'
},
micro: {
bravo: 'Bravo !',
+8 -3
View File
@@ -27,7 +27,8 @@ const gcf: Dict = {
choixMode: 'Chwazi jan ou vlé jwé',
options: 'Règlaj',
timer: 'Kronomèt (60 s, bonus pwen)',
jouer: 'Jwé'
jouer: 'Jwé',
creerLienDefi: 'Réyé on lyen défi'
},
jeu: {
charger: 'Nou ka chajé koté-la…',
@@ -89,7 +90,10 @@ const gcf: Dict = {
telecharger: 'Téléchajé foto-a',
taPosition: 'Pozisyon ou',
positionReelle: 'Rèl pozisyon-an',
manche: 'Mannèk'
manche: 'Mannèk',
titreSeed: 'Défi — seed',
copierLien: 'Kopié lyen défi-a',
lienCopie: 'Lyen kopié !'
},
explorer: {
titre: 'Aprann — Flanné',
@@ -102,7 +106,8 @@ const gcf: Dict = {
ariaLabel: 'Kat jwé-a. Flèch yo pou déplasé makò-a, Antré pou validé.',
sautRegion: 'Alé o péyi',
zoomIn: 'Zoumé',
zoomOut: 'Dézoumé'
zoomOut: 'Dézoumé',
carteRegionsLabel: 'Kat 4 péyi yo : Gwadloup, Matinik, Giyan, Léryinyon'
},
micro: {
bravo: 'Bravo !',
File diff suppressed because one or more lines are too long
+44 -35
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CarteRegions from '$lib/components/CarteRegions.svelte';
import { t } from '$lib/i18n/store.svelte';
import { REGIONS } from '$lib/map/regions';
import type { GameMode, Region } from '$lib/types';
type ChoixRegion = Region | 'TOUTES';
@@ -22,6 +22,15 @@
if (timer) params.set('timer', '1');
goto(`/jeu/${mode}${params.size ? `?${params}` : ''}`);
}
/** Génère une seed lisible (alphabet sans ambiguïté : pas de 0/O, 1/I/L) et ouvre le défi partagé. */
function creerLienDefi(): void {
const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const octets = new Uint8Array(8);
crypto.getRandomValues(octets);
const seed = Array.from(octets, (b) => alphabet[b % alphabet.length]).join('');
goto(`/defi/${seed}`);
}
</script>
<svelte:head>
@@ -38,26 +47,16 @@
<section aria-labelledby="titre-regions">
<h2 id="titre-regions">{t().accueil.choixRegion}</h2>
<div class="grille regions">
{#each REGIONS as r (r)}
<button
type="button"
class="carte-region region-{r.toLowerCase()}"
class:choisi={region === r}
aria-pressed={region === r}
onclick={() => (region = r)}
>
<span class="nom">{t().regions[r]}</span>
</button>
{/each}
<div class="carte-accueil">
<CarteRegions bind:selection={region} />
<button
type="button"
class="carte-region toutes"
class="btn btn-secondaire toutes"
class:choisi={region === 'TOUTES'}
aria-pressed={region === 'TOUTES'}
onclick={() => (region = 'TOUTES')}
>
<span class="nom">{t().regions.TOUTES}</span>
{t().regions.TOUTES}
</button>
</div>
</section>
@@ -84,6 +83,11 @@
<span class="desc">{t().modes.explorerDesc}</span>
</a>
</div>
{#if mode === 'defi'}
<button type="button" class="btn btn-secondaire lien-defi" onclick={creerLienDefi}>
{t().accueil.creerLienDefi}
</button>
{/if}
</section>
<section class="options" aria-labelledby="titre-options">
@@ -148,15 +152,35 @@
gap: 0.7rem;
}
.regions {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.modes {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.carte-region,
/* Carte SVG des régions + bouton « Toutes les régions » */
.carte-accueil {
max-width: 560px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.toutes {
border: 2px solid transparent;
}
.toutes.choisi {
border-color: var(--oki-vert);
background: var(--oki-vert-doux);
}
/* Bouton secondaire « Créer un lien de défi », sous la grille des modes */
.lien-defi {
display: block;
margin: 0.8rem auto 0;
}
.carte-mode {
border: 2px solid transparent;
border-radius: var(--oki-radius);
@@ -166,19 +190,6 @@
text-align: center;
color: var(--oki-encre);
text-decoration: none;
}
.carte-region {
padding: 1.4rem 0.6rem;
font-size: 1.05rem;
font-weight: 700;
}
.carte-region .nom {
color: var(--oki-vert);
}
.carte-mode {
display: flex;
flex-direction: column;
align-items: center;
@@ -200,12 +211,10 @@
color: var(--oki-encre-doux);
}
.carte-region:hover,
.carte-mode:hover {
border-color: var(--oki-vert-clair);
}
.carte-region.choisi,
.carte-mode.choisi {
border-color: var(--oki-vert);
background: var(--oki-vert-doux);
+22 -6
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import GameMap from '$lib/components/GameMap.svelte';
import CarteRegions, { type MarqueurCarte } from '$lib/components/CarteRegions.svelte';
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
import ResultPanel from '$lib/components/ResultPanel.svelte';
import TimerRing from '$lib/components/TimerRing.svelte';
@@ -30,10 +31,23 @@
const estDefi = $derived(mode === 'defi');
const mancheCourante = $derived(defi.manches.length + 1);
const defiTermine = $derived(estDefi && defi.termine);
const paires = $derived(
defi.manches
.filter((m) => m.guess !== null)
.map((m) => ({ from: m.guess!, to: m.result.coordonnees, label: m.result.nom }))
// Marqueurs du récap SVG : position réelle (vert) + guess du joueur (corail),
// numérotés par manche ; une manche passée (guess null) n'a que le point réel.
const marqueursRecap = $derived(
defi.manches.flatMap((m, i): MarqueurCarte[] => {
const reel: MarqueurCarte = {
lat: m.result.coordonnees.lat,
lon: m.result.coordonnees.lon,
type: 'reel',
label: m.result.nom,
paire: i + 1
};
if (!m.guess) return [reel];
return [
reel,
{ lat: m.guess.lat, lon: m.guess.lon, type: 'guess', label: m.result.nom, paire: i + 1 }
];
})
);
async function chargerRound(): Promise<void> {
@@ -191,7 +205,7 @@
<span class="unite">{t().defi.scoreTotal}</span>
</p>
<div class="bilan-carte">
<GameMap interactive={false} pairs={paires} />
<CarteRegions selectionnable={false} marqueurs={marqueursRecap} />
</div>
<ol class="recap">
{#each defi.manches as m, i (m.place.id)}
@@ -482,7 +496,9 @@
}
.bilan-carte {
height: min(46vh, 420px);
width: 100%;
max-width: 520px;
margin: 0 auto;
}
.recap {