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:
Generated
+2864
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 l’image',
|
||||
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 !',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user