Compare commits
3 Commits
8178e87f87
...
223511f718
| Author | SHA1 | Date | |
|---|---|---|---|
| 223511f718 | |||
| ce04a56f1e | |||
| 3f4f284c0a |
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
@@ -0,0 +1,86 @@
|
||||
import rawLieux from './data/lieux.json';
|
||||
import type { Categorie, Lieu } from '$lib/types';
|
||||
|
||||
const lieux = rawLieux as Lieu[];
|
||||
|
||||
/** Nombre de manches d'un défi. */
|
||||
const NB_MANCHES = 5;
|
||||
|
||||
/** Seed acceptée : alphanumérique + tiret/underscore, 4 à 40 caractères. */
|
||||
const SEED_REGEX = /^[A-Za-z0-9_-]{4,40}$/;
|
||||
|
||||
export function seedValide(seed: string): boolean {
|
||||
return SEED_REGEX.test(seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash cyrb53 (domaine public) réduit à 32 bits.
|
||||
* Arithmétique entière via Math.imul : résultat strictement identique sur
|
||||
* toutes les plateformes — prérequis du déterminisme inter-machines (ADR-001 V1).
|
||||
*/
|
||||
function hashSeed(seed: string): number {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const ch = seed.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (h1 ^ h2) >>> 0;
|
||||
}
|
||||
|
||||
/** PRNG mulberry32 (domaine public) : flottants dans [0, 1), déterministes. */
|
||||
function mulberry32(graine: number): () => number {
|
||||
let a = graine;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tire les 5 lieux d'un défi à partir de la seed : mêmes lieux, même ordre,
|
||||
* pour tous les joueurs et sur toutes les machines (ADR-001 v1).
|
||||
* PRNG seedé exclusivement — jamais Math.random.
|
||||
* Contraintes : ≥ 1 MONUMENT et ≥ 1 LIEU, et si possible ≥ 2 régions distinctes.
|
||||
* Fonction pure : aucune I/O réseau, aucun état ; même entrée → même sortie.
|
||||
*/
|
||||
export function idsPourSeed(seed: string): string[] {
|
||||
const rng = mulberry32(hashSeed(seed));
|
||||
|
||||
// Mélange de Fisher-Yates déterministe des indices du corpus.
|
||||
const indices = lieux.map((_, i) => i);
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
const melanges = indices.map((i) => lieux[i]);
|
||||
const tires = melanges.slice(0, NB_MANCHES);
|
||||
|
||||
// ≥ 1 de chaque catégorie : si l'une manque, on échange un tiré contre le
|
||||
// premier non-tiré de la catégorie manquante (l'autre catégorie reste présente).
|
||||
const assurerCategorie = (categorie: Categorie): void => {
|
||||
if (tires.some((l) => l.categorie === categorie)) return;
|
||||
const candidat = melanges.find((l) => l.categorie === categorie && !tires.includes(l));
|
||||
if (candidat) tires[0] = candidat;
|
||||
};
|
||||
assurerCategorie('MONUMENT');
|
||||
assurerCategorie('LIEU');
|
||||
|
||||
// ≥ 2 régions distinctes si possible : on échange un tiré contre un non-tiré
|
||||
// d'une autre région et de MÊME catégorie (contrainte catégories préservée,
|
||||
// les deux étant présentes après l'étape précédente).
|
||||
const regions = new Set(tires.map((l) => l.region));
|
||||
if (regions.size < 2) {
|
||||
const candidat = melanges.find((l) => !tires.includes(l) && !regions.has(l.region));
|
||||
const i = candidat ? tires.findIndex((l) => l.categorie === candidat.categorie) : -1;
|
||||
if (candidat && i >= 0) tires[i] = candidat;
|
||||
}
|
||||
|
||||
return tires.map((l) => l.id);
|
||||
}
|
||||
+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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getLieuById, sanitizeRound } from '$lib/server/lieux';
|
||||
import { idsPourSeed, seedValide } from '$lib/server/tirage';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/defi/[seed] → { rounds: [5 rounds SANITISÉS] }.
|
||||
* Même seed → mêmes lieux, même ordre, pour tous les joueurs (ADR-001 v1).
|
||||
* Même format que /api/round : jamais de coordonnées, commune, région ni wikidata_id ;
|
||||
* les photos passent par le proxy opaque /api/photo/<uuid>.
|
||||
*/
|
||||
export const GET: RequestHandler = ({ params }) => {
|
||||
if (!seedValide(params.seed)) {
|
||||
return json(
|
||||
{ erreur: 'Seed invalide (4–40 caractères : lettres, chiffres, tiret, underscore)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const rounds = idsPourSeed(params.seed).map((id) => {
|
||||
const lieu = getLieuById(id);
|
||||
if (!lieu) throw new Error(`Tirage incohérent : id inconnu (${id})`);
|
||||
return sanitizeRound(lieu);
|
||||
});
|
||||
return json({ rounds }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
};
|
||||
@@ -0,0 +1,589 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
import { t } from '$lib/i18n/store.svelte';
|
||||
import { defi } from '$lib/stores/defi.svelte';
|
||||
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
const seed = $derived(data.seed);
|
||||
const rounds = $derived(data.rounds);
|
||||
|
||||
const timerActif = $derived(page.url.searchParams.get('timer') === '1');
|
||||
|
||||
let place = $state<RoundPlace | null>(null);
|
||||
let guess = $state<Coordonnees | null>(null);
|
||||
let result = $state<GuessResult | null>(null);
|
||||
let indicesMontres = $state<string[]>([]);
|
||||
let erreur = $state(false);
|
||||
let enCours = $state(false); // requête guess en vol
|
||||
|
||||
let timerRef: ReturnType<typeof TimerRing> | undefined = $state();
|
||||
let bilan = $state(false); // écran final du défi
|
||||
let lienCopie = $state(false); // retour visuel du bouton « Copier le lien »
|
||||
|
||||
const mancheCourante = $derived(defi.manches.length + 1);
|
||||
const defiTermine = $derived(defi.termine);
|
||||
// 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 }
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Les 5 lieux sont préchargés par le load : la manche courante suit l'état du store.
|
||||
function chargerRound(): void {
|
||||
guess = null;
|
||||
result = null;
|
||||
indicesMontres = [];
|
||||
place = rounds[defi.manches.length] ?? null;
|
||||
erreur = !place;
|
||||
}
|
||||
|
||||
async function soumettre(skip: boolean): Promise<void> {
|
||||
if (!place || result || enCours) return;
|
||||
if (!skip && !guess) return;
|
||||
enCours = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = { id: place.id };
|
||||
if (skip) {
|
||||
body.skip = true;
|
||||
} else {
|
||||
body.lat = guess!.lat;
|
||||
body.lon = guess!.lon;
|
||||
body.hintsUsed = indicesMontres.length;
|
||||
if (timerActif && timerRef) body.timeMs = timerRef.tempsMs();
|
||||
}
|
||||
const res = await fetch('/api/guess', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
result = (await res.json()) as GuessResult;
|
||||
defi.ajouter({ place, guess: skip ? null : guess, result });
|
||||
} catch {
|
||||
erreur = true;
|
||||
} finally {
|
||||
enCours = false;
|
||||
}
|
||||
}
|
||||
|
||||
function montrerIndice(): void {
|
||||
if (!place) return;
|
||||
const prochain = place.indices.find((i) => !indicesMontres.includes(i.texte));
|
||||
if (prochain) indicesMontres = [...indicesMontres, prochain.texte];
|
||||
}
|
||||
|
||||
function suivant(): void {
|
||||
chargerRound();
|
||||
}
|
||||
|
||||
function montrerBilan(): void {
|
||||
bilan = true;
|
||||
}
|
||||
|
||||
function rejouerDefi(): void {
|
||||
defi.reinitialiser();
|
||||
bilan = false;
|
||||
lienCopie = false;
|
||||
chargerRound();
|
||||
}
|
||||
|
||||
/** Copie l'URL du défi dans le presse-papiers (repli : champ temporaire + sélection). */
|
||||
async function copierLien(): Promise<void> {
|
||||
const url = `${window.location.origin}/defi/${seed}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
// Contexte non sécurisé (HTTP) ou navigateur sans Clipboard API.
|
||||
const champ = document.createElement('textarea');
|
||||
champ.value = url;
|
||||
document.body.appendChild(champ);
|
||||
champ.select();
|
||||
document.execCommand('copy');
|
||||
champ.remove();
|
||||
}
|
||||
lienCopie = true;
|
||||
setTimeout(() => (lienCopie = false), 2500);
|
||||
}
|
||||
|
||||
/** Génère une image canvas téléchargeable : score total + lieux déjà révélés (V3). */
|
||||
function partager(): void {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1080;
|
||||
canvas.height = 1080;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Fond sable + bandeau vert profond
|
||||
ctx.fillStyle = '#f5efe2';
|
||||
ctx.fillRect(0, 0, 1080, 1080);
|
||||
ctx.fillStyle = '#0b3d2e';
|
||||
ctx.fillRect(0, 0, 1080, 240);
|
||||
|
||||
ctx.fillStyle = '#fffdf8';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = '800 96px system-ui, sans-serif';
|
||||
ctx.fillText('JWE', 540, 130);
|
||||
ctx.font = '600 44px system-ui, sans-serif';
|
||||
ctx.fillText(t().modes.defi, 540, 200);
|
||||
|
||||
// Score total
|
||||
ctx.fillStyle = '#0b3d2e';
|
||||
ctx.font = '800 150px system-ui, sans-serif';
|
||||
ctx.fillText(`${defi.total}`, 540, 420);
|
||||
ctx.fillStyle = '#5a6b62';
|
||||
ctx.font = '500 40px system-ui, sans-serif';
|
||||
ctx.fillText(t().defi.scoreTotal, 540, 480);
|
||||
|
||||
// Manches : lieu, région, score
|
||||
let y = 580;
|
||||
for (const [i, m] of defi.manches.entries()) {
|
||||
ctx.fillStyle = i % 2 === 0 ? '#e3efe7' : '#f5efe2';
|
||||
ctx.fillRect(90, y - 44, 900, 74);
|
||||
ctx.fillStyle = '#22302a';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '600 34px system-ui, sans-serif';
|
||||
ctx.fillText(`${i + 1}. ${m.result.nom}`, 120, y);
|
||||
ctx.fillStyle = '#17624a';
|
||||
ctx.font = '500 30px system-ui, sans-serif';
|
||||
ctx.fillText(t().regions[m.result.region], 120, y + 34);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = '#0b3d2e';
|
||||
ctx.font = '800 40px system-ui, sans-serif';
|
||||
ctx.fillText(`${m.result.score}`, 960, y + 8);
|
||||
y += 92;
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#5a6b62';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = '500 30px system-ui, sans-serif';
|
||||
ctx.fillText('labola.o-k-i.net', 540, 1010);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = 'jwe-defi.png';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// (Ré)initialise le défi au montage et à chaque changement de seed :
|
||||
// en navigation client d'un /defi/<seed> vers un autre, le composant est conservé.
|
||||
let seedJouee = '';
|
||||
$effect(() => {
|
||||
if (seed === seedJouee) return;
|
||||
seedJouee = seed;
|
||||
defi.reinitialiser();
|
||||
bilan = false;
|
||||
lienCopie = false;
|
||||
chargerRound();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t().app.titre} — {t().modes.defi} — seed {seed}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="jeu">
|
||||
{#if bilan}
|
||||
<div class="bilan">
|
||||
<h1>{t().defi.bilan}</h1>
|
||||
<p class="seed-discret">{t().defi.titreSeed} <strong>{seed}</strong></p>
|
||||
<p class="total">
|
||||
<span class="valeur">{defi.total}</span>
|
||||
<span class="unite">{t().defi.scoreTotal}</span>
|
||||
</p>
|
||||
<div class="bilan-carte">
|
||||
<CarteRegions selectionnable={false} marqueurs={marqueursRecap} />
|
||||
</div>
|
||||
<ol class="recap">
|
||||
{#each defi.manches as m, i (m.place.id)}
|
||||
<li>
|
||||
<span class="r-nom">{i + 1}. {m.result.nom}</span>
|
||||
<span class="r-region">{t().regions[m.result.region]}</span>
|
||||
<span class="r-score">{m.result.score} {t().resultat.points}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="bilan-actions">
|
||||
<button type="button" class="btn btn-primaire" onclick={partager}>
|
||||
{t().defi.partager}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondaire" onclick={() => void copierLien()}>
|
||||
{lienCopie ? t().defi.lienCopie : t().defi.copierLien}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondaire" onclick={rejouerDefi}>
|
||||
{t().resultat.rejouer}
|
||||
</button>
|
||||
<a href="/" class="btn btn-tertiaire">{t().resultat.retourAccueil}</a>
|
||||
</div>
|
||||
<p class="legende">
|
||||
<span class="puce joueur" aria-hidden="true">●</span> {t().defi.taPosition} —
|
||||
<span class="puce reelle" aria-hidden="true">●</span> {t().defi.positionReelle}
|
||||
</p>
|
||||
</div>
|
||||
{:else if erreur}
|
||||
<div class="etat">
|
||||
<p>{t().jeu.erreur}</p>
|
||||
<button type="button" class="btn btn-secondaire" onclick={chargerRound}>
|
||||
{t().jeu.reessayer}
|
||||
</button>
|
||||
</div>
|
||||
{:else if place}
|
||||
<div class="split" class:resultat={!!result}>
|
||||
<section class="panneau-photo">
|
||||
{#if result}
|
||||
<div class="resultat-scroll">
|
||||
<ResultPanel
|
||||
{result}
|
||||
creditPhoto={place.photo.credit}
|
||||
labelBouton={defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
|
||||
onsuivant={defiTermine ? montrerBilan : suivant}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<PhotoPanel photo={place.photo} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panneau-carte">
|
||||
<GameMap
|
||||
bind:guess
|
||||
real={result ? result.coordonnees : null}
|
||||
interactive={!result}
|
||||
onvalidate={() => void soumettre(false)}
|
||||
/>
|
||||
{#if !defiTermine}
|
||||
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
|
||||
{/if}
|
||||
<span class="badge-seed">{t().defi.titreSeed} {seed}</span>
|
||||
{#if timerActif && !result}
|
||||
<div class="badge-timer">
|
||||
<TimerRing bind:this={timerRef} actif={!result} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if !result}
|
||||
<div class="barre-actions">
|
||||
<div class="actions-secondaires">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-tertiaire"
|
||||
onclick={montrerIndice}
|
||||
disabled={indicesMontres.length >= place.indices.length}
|
||||
>
|
||||
{t().jeu.indice}
|
||||
<span class="penalite">{t().jeu.indicePenalite}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondaire" onclick={() => void soumettre(true)}>
|
||||
{t().jeu.passer}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primaire valider"
|
||||
disabled={!guess || enCours}
|
||||
onclick={() => void soumettre(false)}
|
||||
>
|
||||
{t().jeu.valider}
|
||||
</button>
|
||||
</div>
|
||||
{#if indicesMontres.length > 0}
|
||||
<aside class="indices" aria-live="polite">
|
||||
{#each indicesMontres as texte, i (texte)}
|
||||
<p class="indice"><strong>{t().jeu.indice} {i + 1}</strong> — {texte}</p>
|
||||
{/each}
|
||||
</aside>
|
||||
{/if}
|
||||
{#if !guess}
|
||||
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="etat"><p>{t().jeu.charger}</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.jeu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.etat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
color: var(--oki-encre-doux);
|
||||
}
|
||||
|
||||
.split {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Mobile : photo 60 % haut, carte 40 % bas */
|
||||
.panneau-photo {
|
||||
flex: 0 0 58%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.split.resultat .panneau-photo {
|
||||
flex: 1 1 55%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panneau-carte {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.resultat-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--oki-blanc);
|
||||
border-radius: var(--oki-radius);
|
||||
box-shadow: var(--oki-ombre);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.badge-manche {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
background: rgba(255, 253, 248, 0.94);
|
||||
color: var(--oki-vert);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
box-shadow: var(--oki-ombre);
|
||||
}
|
||||
|
||||
/* Seed visible mais discrète : les joueurs vérifient qu'ils jouent le même défi */
|
||||
.badge-seed {
|
||||
position: absolute;
|
||||
top: 2.7rem;
|
||||
right: 0.6rem;
|
||||
background: rgba(255, 253, 248, 0.8);
|
||||
color: var(--oki-encre-doux);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--oki-ombre);
|
||||
}
|
||||
|
||||
.badge-timer {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
left: 3.4rem; /* à droite des boutons zoom */
|
||||
}
|
||||
|
||||
/* Barre d'actions : fixe en zone du pouce (tiers inférieur) */
|
||||
.barre-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.8rem calc(0.7rem + env(safe-area-inset-bottom));
|
||||
background: var(--oki-blanc);
|
||||
border-top: 1px solid var(--oki-sable-fonce);
|
||||
}
|
||||
|
||||
.actions-secondaires {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-secondaires .btn {
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.penalite {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.valider {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.9rem 1.4rem;
|
||||
}
|
||||
|
||||
.indices {
|
||||
padding: 0 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.indice {
|
||||
margin: 0.25rem 0;
|
||||
background: var(--oki-bleu-doux);
|
||||
border-left: 4px solid var(--oki-bleu);
|
||||
border-radius: var(--oki-radius-petit);
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.aide-carte {
|
||||
margin: 0;
|
||||
padding: 0 1rem 0.6rem;
|
||||
text-align: center;
|
||||
font-size: 0.88rem;
|
||||
color: var(--oki-encre-doux);
|
||||
}
|
||||
|
||||
/* Desktop : 50/50 */
|
||||
@media (min-width: 768px) {
|
||||
.split {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.panneau-photo,
|
||||
.split.resultat .panneau-photo {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
.panneau-carte {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
.barre-actions {
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* — Écran bilan du défi — */
|
||||
.bilan {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 1.2rem 1rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bilan h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--oki-vert);
|
||||
}
|
||||
|
||||
.seed-discret {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--oki-encre-doux);
|
||||
}
|
||||
|
||||
.total {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.total .valeur {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--oki-vert);
|
||||
}
|
||||
|
||||
.total .unite {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--oki-encre-doux);
|
||||
}
|
||||
|
||||
.bilan-carte {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recap {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.recap li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
background: var(--oki-blanc);
|
||||
border-radius: var(--oki-radius-petit);
|
||||
box-shadow: var(--oki-ombre);
|
||||
padding: 0.55rem 0.9rem;
|
||||
}
|
||||
|
||||
.r-nom {
|
||||
font-weight: 700;
|
||||
color: var(--oki-vert);
|
||||
}
|
||||
|
||||
.r-region {
|
||||
flex: 1;
|
||||
color: var(--oki-encre-doux);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.r-score {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bilan-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.legende {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--oki-encre-doux);
|
||||
}
|
||||
|
||||
.puce.joueur {
|
||||
color: var(--oki-corail);
|
||||
}
|
||||
|
||||
.puce.reelle {
|
||||
color: var(--oki-vert);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RoundPlace } from '$lib/types';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
/** Charge les 5 rounds du défi seedé via l'API (mêmes lieux pour tous les joueurs). */
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
const res = await fetch(`/api/defi/${params.seed}`);
|
||||
if (res.status === 400) error(400, 'Seed de défi invalide');
|
||||
if (!res.ok) error(500, 'Impossible de charger ce défi');
|
||||
const data = (await res.json()) as { rounds: RoundPlace[] };
|
||||
return { seed: params.seed, rounds: data.rounds };
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -101,3 +101,8 @@ Règles :
|
||||
- **A1 — Radius de score en Guyane** : voir §5 « Règles ». `aire_commune_km2` (champ optionnel du schéma) élargit le rayon effectif : `sqrt(aire/π)`. Test de référence : Saint-Laurent-du-Maroni (4320,8 km², EXPERT), guess à 40 km → `radiusEffKm` 37,1, score ≈ 1700 (au lieu de ≈ 0). Le code tolère l'absence du champ.
|
||||
- **A2 — Pas de fuite de région** : la caméra initiale de `GameMap` est **globale** (`center: [-58, 0]`, `zoom: 3.6` — vue d'ensemble des 4 régions, aucun indice sur le lieu). Les 4 boutons région sont la **devinette en deux temps** : étape 1 = sauter vers la région supposée, étape 2 = poser le marqueur. Les deux étapes sont combinées par la distance seule : mauvaise région → distance énorme → score ≈ 0. Aucune donnée serveur ne révèle la région avant le guess (`/api/round` ne l'inclut pas).
|
||||
- **A3 — Chaîne Wikipedia** : voir §3. `Cache-Control: max-age=86400` ; cas intermédiaire QID sans frwiki → `404 {"reason":"no-frwiki"}` → variante Cas B dédiée côté front ; fallback de langue fr uniquement, documenté dans le code.
|
||||
|
||||
## 8. Phase 2 (2026-07-17) — ADR-001 v1 et ADR-002
|
||||
|
||||
- **ADR-001 v1 — Défi par lien à seed** (`/defi/<seed>`) : `src/lib/server/tirage.ts` (hash cyrb53 → PRNG mulberry32, Fisher-Yates déterministe, contraintes ≥1 MONUMENT / ≥1 LIEU / ≥2 régions par échanges déterministes — zéro `Math.random`, déterminisme inter-machines). `GET /api/defi/[seed]` → 5 rounds sanitisés identiques pour tous (`no-store`, seed alphanumérique 4–40). Accueil : « Créer un lien de défi » (seed 8 chars sans ambiguïté). Bilan : « Copier le lien du défi ». La comparaison des scores reste humaine (image canvas) — zéro compte, zéro état serveur, zéro leaderboard. La v2 temps réel (PlaySocketJS) reste conditionnée au gate de traction de l'ADR (≥30 parties/sem. ×4 sem. + ≥5 demandes explicites).
|
||||
- **ADR-002 — Carte SVG légère hors-jeu** : pipeline `app/scripts/build-carte-regions.mjs` (geo.api.gouv.fr → mapshaper 7 % → `src/lib/map/regions-geo.json`, 91,8 KB gz ≤ 150 KB) ; composant `CarteRegions.svelte` from scratch (SVG, projection équirectangulaire par inset, 2×2 responsive, `<button>` par région avec `aria-label` FR/créole, SSR sans JS, prop `marqueurs` pour le récap guess→réel, attribution © OSM/données publiques). Utilisée sur l'accueil (sélection de région) et les bilans défi — MapLibre reste strictement aux écrans de jeu (devinette, résultat, explorer). Attribution : contours © geo.api.gouv.fr / données publiques.
|
||||
|
||||
@@ -46,3 +46,11 @@ Date : 2026-07-16 · Référentiel : `prompts/JWE-prompt-verification-agents.md`
|
||||
| R6 | confort | Icônes PWA SVG seulement ; contraste corail 3,7:1 | Générer PNG 192/512 à l'installation ; assombrir l'accent vers ≥ 4,5:1 | S | `static/`, `oki.css` |
|
||||
|
||||
**Verdict passe 2 : CONFORME — 0 FAIL restant.** 4 FAIL corrigés (C2, C4a, A1, A3), 1 script de validation pérenne livré.
|
||||
|
||||
---
|
||||
|
||||
## Addendum (2026-07-17) — R1 et R2 implémentées
|
||||
|
||||
- **R1 (ADR-001 v1)** : livrée. Déterminisme prouvé (même seed → mêmes 5 lieux, même ordre, 2 processus), anti-fuite vérifiée (0 occurrence de champ sensible dans `/api/defi/<seed>`), image de partage inchangée (score uniquement). Voir `docs/architecture.md` §8.
|
||||
- **R2 (ADR-002)** : livrée. `regions-geo.json` = 91,8 KB gz (budget 150 KB) ; SSR de `/` sans MapLibre ni JS requis (4 vrais `<button>` région bilingues) ; bilans défi passés en SVG ; composant from scratch ; attribution présente. V1–V5 PASS.
|
||||
- Les autres recommandations (R3–R6) restent ouvertes.
|
||||
|
||||
Reference in New Issue
Block a user