Compare commits
14 Commits
8178e87f87
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b867243fd | |||
| 67142428ac | |||
| 84c0363067 | |||
| 70f448a4f7 | |||
| 79b2ef72a9 | |||
| e4a0bfd045 | |||
| fab020ba75 | |||
| 2e6f7e8830 | |||
| 19b83159a9 | |||
| 2f8867497a | |||
| c5e925b3c4 | |||
| 223511f718 | |||
| ce04a56f1e | |||
| 3f4f284c0a |
@@ -0,0 +1,6 @@
|
|||||||
|
# Paquet YunoHost (dépôt séparé : ORGANISATION-KA-INTERNATIONALE/jwe_ynh)
|
||||||
|
jwe_ynh/
|
||||||
|
|
||||||
|
# Artefacts de debug Kimi CLI
|
||||||
|
kimi-debug-session*.zip
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# JWE
|
# JWE
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
JWE est un jeu de géolocalisation souverain, dans l'esprit de GeoGuessr, centré sur 4 régions : <strong>Guadeloupe, Martinique, Guyane, La Réunion</strong>. Le joueur voit une photo unique d'un lieu et place un marqueur sur une carte pour deviner la commune — puis découvre une fiche éducative Wikipédia/Wikidata… ou un appel à contribution quand le lieu n'y est pas documenté (<em>Pwen blindé</em>).
|
JWE est un jeu de géolocalisation souverain, dans l'esprit de GeoGuessr, centré sur 4 régions : <strong>Guadeloupe, Martinique, Guyane, La Réunion</strong>. Le joueur voit une photo unique d'un lieu — ou navigue dans une rue en visionneuse Mapillary (mode <em>An lari a</em>) — et place un marqueur sur une carte pour deviner la commune — puis découvre une fiche éducative Wikipédia/Wikidata… ou un appel à contribution quand le lieu n'y est pas documenté (<em>Pwen blindé</em>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -12,10 +12,11 @@ JWE est un jeu de géolocalisation souverain, dans l'esprit de GeoGuessr, centr
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
- **Souverain** : zéro Google, zéro Mapbox. MapLibre + tiles libres, Wikipédia/Wikidata, auto-hébergeable sur un petit VPS.
|
- **Souverain** : zéro Google, zéro Mapbox. MapLibre + tiles libres, Wikipédia/Wikidata, auto-hébergeable sur un petit VPS. (Exception documentée : le mode street « An lari a » utilise la visionneuse Mapillary — images CC BY-SA 4.0, lib MIT, token client gratuit.)
|
||||||
- **Éthique** : pas de comptes, pas de leaderboard, pas de mécanique dopaminergique.
|
- **Éthique** : pas de comptes, pas de leaderboard, pas de mécanique dopaminergique.
|
||||||
- **Bilingue** : français / créole.
|
- **Bilingue** : français / créole.
|
||||||
- **Stack** : Svelte 5 + SvelteKit 2, MapLibre GL JS, Node ≥ 20, licence MIT.
|
- **5 modes** : Konnèt moniman, Kote mwen ye ?, Défi 5 rounds, Aprann (explorer), An lari a (street-level Mapillary).
|
||||||
|
- **Stack** : Svelte 5 + SvelteKit 2, MapLibre GL JS, mapillary-js, Node ≥ 20, licence MIT.
|
||||||
|
|
||||||
## Utilisation
|
## Utilisation
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ JWE est un jeu de géolocalisation souverain, dans l'esprit de GeoGuessr, centr
|
|||||||
git clone https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/JWE.git
|
git clone https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/JWE.git
|
||||||
cd JWE/app
|
cd JWE/app
|
||||||
npm ci
|
npm ci
|
||||||
|
cp .env.example .env # puis renseignez PUBLIC_MAPILLARY_TOKEN (token client gratuit)
|
||||||
npm run dev # développement
|
npm run dev # développement
|
||||||
npm run build && node build # production (adapter-node)
|
npm run build && node build # production (adapter-node)
|
||||||
```
|
```
|
||||||
@@ -33,7 +35,6 @@ npm run build && node build # production (adapter-node)
|
|||||||
- [`docs/deploiement.md`](docs/deploiement.md) — auto-hébergement (SvelteKit + tileserver-gl).
|
- [`docs/deploiement.md`](docs/deploiement.md) — auto-hébergement (SvelteKit + tileserver-gl).
|
||||||
- [`docs/contribution.md`](docs/contribution.md) — ajouter un lieu et lier sa fiche Wikidata.
|
- [`docs/contribution.md`](docs/contribution.md) — ajouter un lieu et lier sa fiche Wikidata.
|
||||||
- [`docs/audit-oki.md`](docs/audit-oki.md) — audit qualité et doctrine OKI.
|
- [`docs/audit-oki.md`](docs/audit-oki.md) — audit qualité et doctrine OKI.
|
||||||
- [`prompts/`](prompts/) — prompts de mission et d'audit ayant produit cette refonte.
|
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
@@ -47,4 +48,4 @@ La version 1 (PHP, cartes des communes) a été remplacée par cette refonte en
|
|||||||
|
|
||||||
Copyright (C) 2024-2026 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
Copyright (C) 2024-2026 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
||||||
|
|
||||||
Code sous licence **MIT** (voir [LICENSE](LICENSE)). Les photographies restent la propriété de leurs auteurs sous leurs licences Wikimedia Commons (voir [`content/credits.md`](content/credits.md)) ; les extraits Wikipédia sont sous CC BY-SA 4.0.
|
Code sous licence **MIT** (voir [LICENSE](LICENSE)). Les photographies restent la propriété de leurs auteurs sous leurs licences Wikimedia Commons ; les images street-level Mapillary sont sous CC BY-SA 4.0 par leurs contributeurs (voir [`content/credits.md`](content/credits.md)) ; les extraits Wikipédia sont sous CC BY-SA 4.0.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Token client Mapillary (gratuit) : https://www.mapillary.com/dashboard/developers
|
||||||
|
# Requis uniquement pour le mode street « An lari a » (visionneuse mapillary-js).
|
||||||
|
# Token client : conçu pour être exposé côté navigateur (rate-limité par app).
|
||||||
|
PUBLIC_MAPILLARY_TOKEN=
|
||||||
|
|
||||||
|
# Optionnel : URL d'un style/tiles auto-hébergé (tileserver-gl) — voir docs/deploiement.md
|
||||||
|
# PUBLIC_TILES_URL=
|
||||||
Generated
+3150
-4
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jwe",
|
"name": "jwe",
|
||||||
"version": "2.0.0",
|
"version": "2.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,12 +15,14 @@
|
|||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
|
"mapshaper": "^0.7.45",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"mapillary-js": "^4.1.2",
|
||||||
"maplibre-gl": "^5.1.0",
|
"maplibre-gl": "^5.1.0",
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Curation du corpus street (« An lari a ») — mode Mapillary.
|
||||||
|
* « Fetch, pas recall » : pour chaque commune cible, interroge l'API Mapillary
|
||||||
|
* (bbox autour du centre communal, préférence 360° + qualité + récence),
|
||||||
|
* geo.api.gouv.fr (centre, superficie), api-adresse (nom de rue) et Wikidata
|
||||||
|
* (QID de la commune via le sitelink frwiki).
|
||||||
|
*
|
||||||
|
* Usage : MAPILLARY_TOKEN=… node scripts/curate-street.mjs
|
||||||
|
* (le token est aussi lu depuis .env : MAPILLARY_TOKEN ou PUBLIC_MAPILLARY_TOKEN)
|
||||||
|
* Écrit src/lib/server/data/street.json.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function chargeEnv() {
|
||||||
|
try {
|
||||||
|
for (const ligne of readFileSync(join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||||
|
const m = ligne.match(/^\s*([A-Z_]+)\s*=\s*(.+?)\s*$/);
|
||||||
|
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* pas de .env */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chargeEnv();
|
||||||
|
const TOKEN = process.env.MAPILLARY_TOKEN || process.env.PUBLIC_MAPILLARY_TOKEN;
|
||||||
|
if (!TOKEN) {
|
||||||
|
console.error('Token Mapillary manquant (MAPILLARY_TOKEN ou PUBLIC_MAPILLARY_TOKEN).');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UA = { 'User-Agent': 'JWE-OKI/2.0 (curation corpus street; contact: o-k-i.net)' };
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const getJSON = async (url, headers = UA) => {
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Communes cibles : 5 par région, difficulté = précision exigée (pas de FACILE :
|
||||||
|
// rayon 50 km = trivial en mode street). titreWiki = titre frwiki exact (désambiguïsé).
|
||||||
|
const SPOTS = [
|
||||||
|
// — Guadeloupe (971) —
|
||||||
|
{ region: 'GUADELOUPE', commune: 'Pointe-à-Pitre', titreWiki: 'Pointe-à-Pitre', difficulte: 'MOYEN', indice1: 'Île papillon, aile droite', indice2: 'Port de commerce principal de l’archipel' },
|
||||||
|
{ region: 'GUADELOUPE', commune: 'Les Abymes', titreWiki: 'Les Abymes', difficulte: 'MOYEN', indice1: 'Île papillon, aile droite', indice2: 'Commune la plus peuplée de l’archipel' },
|
||||||
|
{ region: 'GUADELOUPE', commune: 'Le Gosier', titreWiki: 'Le Gosier', difficulte: 'DIFFICILE', indice1: 'Île papillon, aile droite', indice2: 'Station balnéaire au sud de la Grande-Terre' },
|
||||||
|
{ region: 'GUADELOUPE', commune: 'Sainte-Anne', titreWiki: 'Sainte-Anne (Guadeloupe)', difficulte: 'DIFFICILE', indice1: 'Île papillon, aile droite', indice2: 'Plages au sud de la Grande-Terre' },
|
||||||
|
{ region: 'GUADELOUPE', commune: 'Basse-Terre', titreWiki: 'Basse-Terre', difficulte: 'EXPERT', indice1: 'Île papillon, aile gauche', indice2: 'Chef-lieu au pied du volcan' },
|
||||||
|
// — Martinique (972) —
|
||||||
|
{ region: 'MARTINIQUE', commune: 'Fort-de-France', titreWiki: 'Fort-de-France', difficulte: 'MOYEN', indice1: 'Île aux fleurs', indice2: 'Chef-lieu, sur une grande baie' },
|
||||||
|
{ region: 'MARTINIQUE', commune: 'Le Lamentin', titreWiki: 'Le Lamentin', difficulte: 'MOYEN', indice1: 'Île aux fleurs', indice2: 'Banlieue du chef-lieu, zone aéroportuaire' },
|
||||||
|
{ region: 'MARTINIQUE', commune: 'Saint-Pierre', titreWiki: 'Saint-Pierre (Martinique)', difficulte: 'DIFFICILE', indice1: 'Île aux fleurs, côte caraïbe nord', indice2: 'Ville détruite par une éruption en 1902' },
|
||||||
|
{ region: 'MARTINIQUE', commune: 'Le François', titreWiki: 'Le François', difficulte: 'DIFFICILE', indice1: 'Île aux fleurs, côte atlantique', indice2: 'Bourg face aux îlets du sud' },
|
||||||
|
{ region: 'MARTINIQUE', commune: 'Sainte-Marie', titreWiki: 'Sainte-Marie (Martinique)', difficulte: 'EXPERT', indice1: 'Île aux fleurs, côte atlantique nord', indice2: 'Musée de la banane et rhum' },
|
||||||
|
// — Guyane (973) —
|
||||||
|
{ region: 'GUYANE', commune: 'Cayenne', titreWiki: 'Cayenne', difficulte: 'MOYEN', indice1: 'Terre de forêt amazonienne', indice2: 'Chef-lieu, sur l’île du même nom' },
|
||||||
|
{ region: 'GUYANE', commune: 'Kourou', titreWiki: 'Kourou', difficulte: 'DIFFICILE', indice1: 'Terre de forêt amazonienne', indice2: 'Ville des fusées' },
|
||||||
|
{ region: 'GUYANE', commune: 'Saint-Laurent-du-Maroni', titreWiki: 'Saint-Laurent-du-Maroni', difficulte: 'DIFFICILE', indice1: 'Terre de forêt amazonienne, à l’ouest', indice2: 'Ville du fleuve Maroni, ancien bagne' },
|
||||||
|
{ region: 'GUYANE', commune: 'Matoury', titreWiki: 'Matoury', difficulte: 'EXPERT', indice1: 'Terre de forêt amazonienne', indice2: 'Banlieue sud du chef-lieu' },
|
||||||
|
{ region: 'GUYANE', commune: 'Remire-Montjoly', titreWiki: 'Remire-Montjoly', difficulte: 'EXPERT', indice1: 'Terre de forêt amazonienne', indice2: 'Plages à l’est du chef-lieu' },
|
||||||
|
// — La Réunion (974) —
|
||||||
|
{ region: 'REUNION', commune: 'Saint-Denis', titreWiki: 'Saint-Denis (La Réunion)', difficulte: 'MOYEN', indice1: 'Île intense de l’océan Indien', indice2: 'Chef-lieu, au nord' },
|
||||||
|
{ region: 'REUNION', commune: 'Saint-Pierre', titreWiki: 'Saint-Pierre (La Réunion)', difficulte: 'MOYEN', indice1: 'Île intense de l’océan Indien', indice2: 'Capitale du sud, front de mer animé' },
|
||||||
|
{ region: 'REUNION', commune: 'Le Tampon', titreWiki: 'Le Tampon', difficulte: 'DIFFICILE', indice1: 'Île intense de l’océan Indien', indice2: 'Commune des hauts, dans le sud' },
|
||||||
|
{ region: 'REUNION', commune: 'Saint-Paul', titreWiki: 'Saint-Paul (La Réunion)', difficulte: 'DIFFICILE', indice1: 'Île intense de l’océan Indien', indice2: 'Côte ouest, premier site de peuplement' },
|
||||||
|
{ region: 'REUNION', commune: 'Saint-André', titreWiki: 'Saint-André (La Réunion)', difficulte: 'EXPERT', indice1: 'Île intense de l’océan Indien', indice2: 'Côte est, pays de la canne' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREFIXE_REGION = { GUADELOUPE: '971', MARTINIQUE: '972', GUYANE: '973', REUNION: '974' };
|
||||||
|
|
||||||
|
async function centreCommune(spot) {
|
||||||
|
const data = await getJSON(
|
||||||
|
`https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(spot.commune)}&codeDepartement=${PREFIXE_REGION[spot.region]}&fields=centre,surface,code,mairie&limit=8`
|
||||||
|
);
|
||||||
|
const c = data[0];
|
||||||
|
if (!c) throw new Error(`commune introuvable sur geo.api.gouv.fr`);
|
||||||
|
// La mairie est un meilleur ancrage que le centroïde (les communes DOM
|
||||||
|
// englobent des reliefs : le centroïde tombe souvent hors du centre-ville).
|
||||||
|
const p = c.mairie?.coordinates ? c.mairie.coordinates : c.centre.coordinates;
|
||||||
|
return { lat: p[1], lon: p[0], surfaceHa: c.surface };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chercherImage(lat, lon) {
|
||||||
|
const champs = 'id,is_pano,captured_at,computed_geometry,geometry,creator,quality_score';
|
||||||
|
const note = (img) => (img.quality_score ?? 0.5) * 1e13 + (img.captured_at ?? 0);
|
||||||
|
// 1) bbox autour de l'ancre (< 0,01° de côté, contrainte API), pano d'abord
|
||||||
|
const demi = 0.0045;
|
||||||
|
const bbox = `${lon - demi},${lat - demi},${lon + demi},${lat + demi}`;
|
||||||
|
for (const pano of ['&is_pano=true', '']) {
|
||||||
|
const data = await getJSON(
|
||||||
|
`https://graph.mapillary.com/images?access_token=${encodeURIComponent(TOKEN)}&fields=${champs}&bbox=${bbox}&limit=200${pano}`
|
||||||
|
);
|
||||||
|
if (data.data?.length) {
|
||||||
|
const triees = [...data.data].sort((a, b) => note(b) - note(a));
|
||||||
|
return { image: triees[0], total: data.data.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) grille de recherches par rayon (50 m) autour de l'ancre
|
||||||
|
const parId = new Map();
|
||||||
|
for (const dLat of [-0.004, 0, 0.004]) {
|
||||||
|
for (const dLon of [-0.004, 0, 0.004]) {
|
||||||
|
try {
|
||||||
|
const data = await getJSON(
|
||||||
|
`https://graph.mapillary.com/images?access_token=${encodeURIComponent(TOKEN)}&fields=${champs}&lat=${lat + dLat}&lng=${lon + dLon}&radius=50&limit=10`
|
||||||
|
);
|
||||||
|
for (const img of data.data ?? []) parId.set(img.id, img);
|
||||||
|
} catch {
|
||||||
|
/* point sans couverture */
|
||||||
|
}
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parId.size) return null;
|
||||||
|
const triees = [...parId.values()].sort((a, b) => note(b) - note(a));
|
||||||
|
return { image: triees[0], total: parId.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nomDeRue(lat, lon) {
|
||||||
|
try {
|
||||||
|
const data = await getJSON(`https://api-adresse.data.gouv.fr/reverse/?lon=${lon}&lat=${lat}&type=street`);
|
||||||
|
const f = data.features?.[0]?.properties;
|
||||||
|
return f?.name ?? f?.street ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QIDs des communes via sitelinks frwiki (un seul appel batch)
|
||||||
|
async function qidsCommunes() {
|
||||||
|
const titres = SPOTS.map((s) => s.titreWiki);
|
||||||
|
const data = await getJSON(
|
||||||
|
`https://www.wikidata.org/w/api.php?action=wbgetentities&sites=frwiki&titles=${encodeURIComponent(titres.join('|'))}&props=sitelinks&sitefilter=frwiki&format=json&redirects=yes`
|
||||||
|
);
|
||||||
|
const parTitre = {};
|
||||||
|
for (const [qid, e] of Object.entries(data.entities ?? {})) {
|
||||||
|
if (e.sitelinks?.frwiki?.title) parTitre[e.sitelinks.frwiki.title] = qid;
|
||||||
|
}
|
||||||
|
const redirige = {};
|
||||||
|
for (const r of data.redirects ?? []) redirige[r.from] = r.to;
|
||||||
|
const res = {};
|
||||||
|
for (const s of SPOTS) {
|
||||||
|
const t = redirige[s.titreWiki] ?? s.titreWiki;
|
||||||
|
res[s.titreWiki] = parTitre[t] ?? null;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qids = await qidsCommunes();
|
||||||
|
const lieux = [];
|
||||||
|
let n = 0;
|
||||||
|
for (const spot of SPOTS) {
|
||||||
|
n++;
|
||||||
|
try {
|
||||||
|
const centre = await centreCommune(spot);
|
||||||
|
await sleep(150);
|
||||||
|
const trouve = await chercherImage(centre.lat, centre.lon);
|
||||||
|
if (!trouve) {
|
||||||
|
console.log(`VIDE ${spot.commune} (${spot.region}) — aucune image dans la bbox`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const img = trouve.image;
|
||||||
|
const geo = img.computed_geometry ?? img.geometry;
|
||||||
|
const coords = { lat: geo.coordinates[1], lon: geo.coordinates[0] };
|
||||||
|
await sleep(150);
|
||||||
|
const rue = await nomDeRue(coords.lat, coords.lon);
|
||||||
|
const qid = qids[spot.titreWiki];
|
||||||
|
lieux.push({
|
||||||
|
id: `st-${String(n).padStart(2, '0')}`,
|
||||||
|
nom: rue ? `${rue}, ${spot.commune}` : `Centre de ${spot.commune}`,
|
||||||
|
nom_creole: `Lari ${spot.commune}`,
|
||||||
|
region: spot.region,
|
||||||
|
commune: spot.commune,
|
||||||
|
coordonnees: coords,
|
||||||
|
difficulte: spot.difficulte,
|
||||||
|
mapillary_id: img.id,
|
||||||
|
credit: {
|
||||||
|
username: img.creator?.username ?? 'contributeur Mapillary',
|
||||||
|
captured_at: new Date(img.captured_at).toISOString().slice(0, 10)
|
||||||
|
},
|
||||||
|
is_pano: !!img.is_pano,
|
||||||
|
wikidata_id: qid,
|
||||||
|
indices: [
|
||||||
|
{ niveau: 1, texte: spot.indice1 },
|
||||||
|
{ niveau: 2, texte: spot.indice2 }
|
||||||
|
],
|
||||||
|
aire_commune_km2: Math.round(centre.surfaceHa) / 100 // geo.api : hectares → km²
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`OK ${spot.commune.padEnd(24)} img ${img.id} pano=${img.is_pano ? 'oui' : 'NON'} q=${img.quality_score?.toFixed(2) ?? '—'} pool=${trouve.total} qid=${qid ?? 'ABSENT'} rue=${rue ?? '—'}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`ERREUR ${spot.commune} — ${e.message}`);
|
||||||
|
}
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(join(ROOT, 'src/lib/server/data/street.json'), JSON.stringify(lieux, null, 2) + '\n');
|
||||||
|
console.log(`\n${lieux.length} lieux écrits dans src/lib/server/data/street.json`);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Validation du corpus street (« An lari a », mode Mapillary).
|
||||||
|
* « Fetch, pas recall » : chaque mapillary_id est revérifié contre l'API
|
||||||
|
* Mapillary (existence, computed_geometry < 100 m de la vérité curée, crédit
|
||||||
|
* auteur, flag pano) et chaque wikidata_id contre Wikidata (sitelink frwiki).
|
||||||
|
*
|
||||||
|
* Usage : node scripts/validate-street.mjs
|
||||||
|
* Token lu depuis .env (MAPILLARY_TOKEN ou PUBLIC_MAPILLARY_TOKEN).
|
||||||
|
* Exit code 1 si au moins un FAIL.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const STREET = JSON.parse(readFileSync(join(ROOT, 'src/lib/server/data/street.json'), 'utf8'));
|
||||||
|
const UA = { 'User-Agent': 'JWE-OKI/2.0 (validation corpus street; contact: o-k-i.net)' };
|
||||||
|
|
||||||
|
function chargeEnv() {
|
||||||
|
try {
|
||||||
|
for (const ligne of readFileSync(join(ROOT, '.env'), 'utf8').split('\n')) {
|
||||||
|
const m = ligne.match(/^\s*([A-Z_]+)\s*=\s*(.+?)\s*$/);
|
||||||
|
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* pas de .env */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chargeEnv();
|
||||||
|
const TOKEN = process.env.MAPILLARY_TOKEN || process.env.PUBLIC_MAPILLARY_TOKEN;
|
||||||
|
|
||||||
|
const getJSON = async (url, headers = UA, essais = 3) => {
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
// 429 (rate limit Wikidata/Mapillary) ou 5xx : backoff puis retry
|
||||||
|
if ((res.status === 429 || res.status >= 500) && i < essais - 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000 * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${res.status} sur ${url}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let fails = 0;
|
||||||
|
const fail = (id, msg) => {
|
||||||
|
fails++;
|
||||||
|
console.log(`FAIL ${id} — ${msg}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const distKm = (a, b) => {
|
||||||
|
const R = 6371,
|
||||||
|
dLa = ((b.lat - a.lat) * Math.PI) / 180,
|
||||||
|
dLo = ((b.lon - a.lon) * Math.PI) / 180;
|
||||||
|
const h =
|
||||||
|
Math.sin(dLa / 2) ** 2 +
|
||||||
|
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLo / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Schéma ------------------------------------------------------------------
|
||||||
|
const CHAMPS = ['id', 'nom', 'nom_creole', 'region', 'commune', 'coordonnees', 'difficulte', 'mapillary_id', 'credit', 'wikidata_id', 'indices'];
|
||||||
|
const REGIONS = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
||||||
|
const ids = new Set();
|
||||||
|
const mlyIds = new Set();
|
||||||
|
for (const l of STREET) {
|
||||||
|
for (const c of CHAMPS) if (!(c in l)) fail(l.nom, `champ manquant: ${c}`);
|
||||||
|
if (ids.has(l.id)) fail(l.nom, `id dupliqué ${l.id}`);
|
||||||
|
ids.add(l.id);
|
||||||
|
if (mlyIds.has(l.mapillary_id)) fail(l.nom, `mapillary_id dupliqué ${l.mapillary_id}`);
|
||||||
|
mlyIds.add(l.mapillary_id);
|
||||||
|
if (!REGIONS.includes(l.region)) fail(l.nom, `région invalide: ${l.region}`);
|
||||||
|
if (!['FACILE', 'MOYEN', 'DIFFICILE', 'EXPERT'].includes(l.difficulte)) fail(l.nom, `difficulté invalide`);
|
||||||
|
if (!l.wikidata_id) fail(l.nom, `wikidata_id null (le mode street documente la commune)`);
|
||||||
|
if (!(l.aire_commune_km2 > 0)) fail(l.nom, `aire_commune_km2 invalide`);
|
||||||
|
}
|
||||||
|
const parRegion = Object.groupBy(STREET, (l) => l.region);
|
||||||
|
for (const r of REGIONS) {
|
||||||
|
const n = parRegion[r]?.length ?? 0;
|
||||||
|
if (n < 3) fail(r, `seulement ${n} lieux (< 3)`);
|
||||||
|
}
|
||||||
|
console.log(`Schéma : ${STREET.length} lieux contrôlés (${REGIONS.map((r) => `${r}=${parRegion[r]?.length ?? 0}`).join(', ')}).`);
|
||||||
|
|
||||||
|
if (!TOKEN) {
|
||||||
|
console.log('Token Mapillary absent — vérifications API sautées (schéma seul).');
|
||||||
|
} else {
|
||||||
|
// --- Mapillary : existence, géométrie, crédit, pano -------------------------
|
||||||
|
console.log('\n| Lieu | mapillary_id | écart coords | crédit | pano | PASS/FAIL |');
|
||||||
|
console.log('|---|---|---|---|---|---|');
|
||||||
|
for (const l of STREET) {
|
||||||
|
try {
|
||||||
|
const img = await getJSON(
|
||||||
|
`https://graph.mapillary.com/${l.mapillary_id}?access_token=${encodeURIComponent(TOKEN)}&fields=id,is_pano,captured_at,computed_geometry,creator`
|
||||||
|
);
|
||||||
|
const geo = img.computed_geometry;
|
||||||
|
const d = distKm(l.coordonnees, { lat: geo.coordinates[1], lon: geo.coordinates[0] });
|
||||||
|
const notes = [];
|
||||||
|
let ok = true;
|
||||||
|
if (d > 0.1) {
|
||||||
|
ok = false;
|
||||||
|
notes.push(`écart ${(d * 1000).toFixed(0)} m > 100 m`);
|
||||||
|
}
|
||||||
|
if ((img.creator?.username ?? null) !== l.credit.username) {
|
||||||
|
ok = false;
|
||||||
|
notes.push(`crédit « ${l.credit.username} » ≠ « ${img.creator?.username} »`);
|
||||||
|
}
|
||||||
|
if (!!img.is_pano !== !!l.is_pano) notes.push(`pano API=${img.is_pano} corpus=${l.is_pano} (non bloquant)`);
|
||||||
|
if (!ok) fail(l.nom, notes.join(' ; '));
|
||||||
|
console.log(`| ${l.nom} | ${l.mapillary_id} | ${(d * 1000).toFixed(0)} m | ${l.credit.username} | ${img.is_pano ? 'oui' : 'non'} | ${ok ? 'PASS' : 'FAIL'} |`);
|
||||||
|
} catch (e) {
|
||||||
|
fail(l.nom, `Mapillary : ${e.message}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wikidata : sitelink frwiki pour chaque commune ---------------------------
|
||||||
|
const qids = STREET.map((l) => l.wikidata_id).filter(Boolean).join('|');
|
||||||
|
const data = await getJSON(
|
||||||
|
`https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${qids}&props=sitelinks&sitefilter=frwiki&format=json`
|
||||||
|
);
|
||||||
|
console.log('\n| Lieu | QID commune | sitelink frwiki | PASS/FAIL |');
|
||||||
|
console.log('|---|---|---|---|');
|
||||||
|
for (const l of STREET) {
|
||||||
|
const site = data.entities?.[l.wikidata_id]?.sitelinks?.frwiki?.title;
|
||||||
|
if (!site) fail(l.nom, `${l.wikidata_id} sans sitelink frwiki`);
|
||||||
|
console.log(`| ${l.nom} | ${l.wikidata_id} | ${site ?? 'ABSENT'} | ${site ? 'PASS' : 'FAIL'} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fails ? `\n${fails} FAIL.` : '\nTout est conforme.');
|
||||||
|
process.exit(fails ? 1 : 0);
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0b3d2e" />
|
<meta name="theme-color" content="#0b3d2e" />
|
||||||
|
<script>
|
||||||
|
// Gate motion : les états cachés initiaux (data-reveal, masques) ne
|
||||||
|
// s'appliquent que si JS est actif — sans JS, tout reste visible.
|
||||||
|
document.documentElement.classList.add('js');
|
||||||
|
</script>
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSP — domaines justifiés :
|
||||||
|
* - tiles.openfreemap.org : style MapLibre (tuiles vectorielles + raster
|
||||||
|
* natural_earth, sprites, glyphs — cf. src/lib/map/style-oki.json) ;
|
||||||
|
* - upload.wikimedia.org : vignettes des fiches Wikipédia (wiki.thumbnail,
|
||||||
|
* affichée en <img>) et photos Commons (service worker, Special:FilePath) ;
|
||||||
|
* - commons.wikimedia.org : variantes Special:FilePath (service worker) ;
|
||||||
|
* - graph.mapillary.com : API Mapillary (mapillary-js, seul domaine en dur
|
||||||
|
* dans la lib) ; tiles.mapillary.com : tuiles vectorielles de couverture ;
|
||||||
|
* images.mapillary.com : CDN des photos 360° (fetch ArrayBuffer → blob:) ;
|
||||||
|
* - www.wikidata.org / fr.wikipedia.org : appelées côté serveur uniquement
|
||||||
|
* (proxy /api/wiki), listées ici par précaution si un appel direct apparaît ;
|
||||||
|
* - 'unsafe-inline' sur script-src : script inline de app.html (gate motion)
|
||||||
|
* + payload d'hydratation injecté par SvelteKit adapter-node ;
|
||||||
|
* - worker-src blob: : workers WebGL de MapLibre chargés en blob.
|
||||||
|
*/
|
||||||
|
const CSP = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://tiles.openfreemap.org",
|
||||||
|
"font-src 'self' data: https://tiles.openfreemap.org",
|
||||||
|
"connect-src 'self' https://tiles.openfreemap.org https://graph.mapillary.com https://tiles.mapillary.com https://images.mapillary.com https://www.wikidata.org https://fr.wikipedia.org https://upload.wikimedia.org https://commons.wikimedia.org",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"object-src 'none'"
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
// Le jeu n'utilise ni caméra, ni micro, ni géolocalisation (la position est
|
||||||
|
// devinée au clic sur la carte) — Permissions-Policy les coupe explicitement.
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const response = await resolve(event);
|
||||||
|
response.headers.set('Content-Security-Policy', CSP);
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
response.headers.set('X-Frame-Options', 'DENY');
|
||||||
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
|
return response;
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<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';
|
||||||
|
import { reveal } from '$lib/motion/reveal';
|
||||||
|
import { syncopatedDelay } from '$lib/motion/tokens';
|
||||||
|
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Base d'index de la cascade syncopée d'entrée des 4 insets (accueil :
|
||||||
|
* 5, après les 5 cartes de mode). null (défaut) = pas de cascade.
|
||||||
|
*/
|
||||||
|
cascadeBase?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
selection = $bindable<SelectionCarte>(null),
|
||||||
|
selectionnable = true,
|
||||||
|
marqueurs = [],
|
||||||
|
attribution = '© contributeurs OpenStreetMap / données publiques',
|
||||||
|
cascadeBase = null
|
||||||
|
}: 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, i (ins.region)}
|
||||||
|
<g
|
||||||
|
class="region"
|
||||||
|
class:survolee={selectionnable && survol === ins.region}
|
||||||
|
class:choisie={selectionnable && selection === ins.region}
|
||||||
|
data-reveal={cascadeBase === null ? undefined : ''}
|
||||||
|
style:--d={cascadeBase === null ? undefined : `${syncopatedDelay(cascadeBase + i)}ms`}
|
||||||
|
use:reveal={cascadeBase !== null}
|
||||||
|
>
|
||||||
|
{#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>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { t } from '$lib/i18n/store.svelte';
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
import { MAX_BOUNDS, MIN_ZOOM, REGION_BOUNDS, REGIONS } from '$lib/map/regions';
|
import { MAX_BOUNDS, MIN_ZOOM, REGION_BOUNDS, REGIONS } from '$lib/map/regions';
|
||||||
import styleOki from '$lib/map/style-oki.json';
|
import styleOki from '$lib/map/style-oki.json';
|
||||||
|
import { dureeMs, easeKa, syncopatedDelay } from '$lib/motion/tokens';
|
||||||
import type { Coordonnees, Region } from '$lib/types';
|
import type { Coordonnees, Region } from '$lib/types';
|
||||||
import type { Map as MLMap, Marker, StyleSpecification } from 'maplibre-gl';
|
import type { Map as MLMap, Marker, StyleSpecification } from 'maplibre-gl';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
guess?: Coordonnees | null;
|
guess?: Coordonnees | null;
|
||||||
real?: Coordonnees | null;
|
real?: Coordonnees | null;
|
||||||
|
distanceKm?: number | null;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
pairs?: Paire[];
|
pairs?: Paire[];
|
||||||
markers?: MarqueurInfo[];
|
markers?: MarqueurInfo[];
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
let {
|
let {
|
||||||
guess = $bindable(null),
|
guess = $bindable(null),
|
||||||
real = null,
|
real = null,
|
||||||
|
distanceKm = null,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
pairs = [],
|
pairs = [],
|
||||||
markers = [],
|
markers = [],
|
||||||
@@ -48,7 +51,9 @@
|
|||||||
let guessMarker: Marker | null = null;
|
let guessMarker: Marker | null = null;
|
||||||
let realMarker: Marker | null = null;
|
let realMarker: Marker | null = null;
|
||||||
let explorerMarkers: Marker[] = [];
|
let explorerMarkers: Marker[] = [];
|
||||||
let raf = 0;
|
let observateurTaille: ResizeObserver | null = null;
|
||||||
|
let rafLigne: number | null = null; // tracé progressif de la ligne de distance
|
||||||
|
let pulseAttendu = false; // pulse du marqueur au prochain positionnement (clic / dragend)
|
||||||
|
|
||||||
const SRC_LIGNE = 'jwe-ligne';
|
const SRC_LIGNE = 'jwe-ligne';
|
||||||
const SRC_PAIRES = 'jwe-paires';
|
const SRC_PAIRES = 'jwe-paires';
|
||||||
@@ -92,6 +97,13 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function annulerTraceLigne(): void {
|
||||||
|
if (rafLigne !== null) {
|
||||||
|
cancelAnimationFrame(rafLigne);
|
||||||
|
rafLigne = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dessinerRevelation(): void {
|
function dessinerRevelation(): void {
|
||||||
if (!map || !ml || !real) return;
|
if (!map || !ml || !real) return;
|
||||||
ensureSource(SRC_LIGNE);
|
ensureSource(SRC_LIGNE);
|
||||||
@@ -100,48 +112,86 @@
|
|||||||
id: 'jwe-ligne-line',
|
id: 'jwe-ligne-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: SRC_LIGNE,
|
source: SRC_LIGNE,
|
||||||
|
filter: ['==', ['geometry-type'], 'LineString'],
|
||||||
paint: { 'line-color': '#e4572e', 'line-width': 3, 'line-dasharray': [2, 1.5] }
|
paint: { 'line-color': '#e4572e', 'line-width': 3, 'line-dasharray': [2, 1.5] }
|
||||||
});
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'jwe-ligne-label',
|
||||||
|
type: 'symbol',
|
||||||
|
source: SRC_LIGNE,
|
||||||
|
filter: ['==', ['geometry-type'], 'Point'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'etiquette'],
|
||||||
|
'text-size': 14,
|
||||||
|
'text-font': ['Noto Sans Regular'],
|
||||||
|
'text-offset': [0, -1.4]
|
||||||
|
},
|
||||||
|
paint: { 'text-color': '#0b3d2e', 'text-halo-color': '#fffdf8', 'text-halo-width': 2 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const positionReelle = real;
|
||||||
|
/** Features de la ligne guess ↔ réel à la progression donnée (1 = complète). */
|
||||||
|
function featuresLigne(progression: number): GeoJSON.Feature[] {
|
||||||
|
if (!guess) return [];
|
||||||
|
const tete = {
|
||||||
|
lat: guess.lat + (positionReelle.lat - guess.lat) * progression,
|
||||||
|
lon: guess.lon + (positionReelle.lon - guess.lon) * progression
|
||||||
|
};
|
||||||
|
const features: GeoJSON.Feature[] = [ligneFeature(guess, tete, null)];
|
||||||
|
if (progression >= 1 && distanceKm !== null) {
|
||||||
|
// Étiquette de distance au milieu de la ligne, posée à la fin du tracé
|
||||||
|
features.push(
|
||||||
|
pointFeature(
|
||||||
|
{ lat: (guess.lat + positionReelle.lat) / 2, lon: (guess.lon + positionReelle.lon) / 2 },
|
||||||
|
{ etiquette: `${distanceKm} km` }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return features;
|
||||||
}
|
}
|
||||||
const features: GeoJSON.Feature[] = guess ? [ligneFeature(guess, real, null)] : [];
|
|
||||||
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features });
|
|
||||||
|
|
||||||
// Marqueur vert = position réelle
|
annulerTraceLigne();
|
||||||
|
const dureeTrace = dureeMs('--dur-phrase');
|
||||||
|
if (guess && !reducedMotion && dureeTrace > 0) {
|
||||||
|
// La ligne de distance se dessine depuis le guess vers le lieu réel :
|
||||||
|
// croissance progressive du GeoJSON via rAF (jamais d'état réactif
|
||||||
|
// par frame), easing --ease-ka, durée lue du token CSS.
|
||||||
|
const t0 = performance.now();
|
||||||
|
const tick = (t: number): void => {
|
||||||
|
const p = Math.min(1, (t - t0) / dureeTrace);
|
||||||
|
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: featuresLigne(easeKa(p)) });
|
||||||
|
rafLigne = p < 1 ? requestAnimationFrame(tick) : null;
|
||||||
|
};
|
||||||
|
rafLigne = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: featuresLigne(1) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marqueur vert = position réelle ; le marqueur corail du joueur RESTE
|
||||||
|
// à sa position : la comparaison visuelle guess ↔ réel est le cœur du jeu.
|
||||||
realMarker?.remove();
|
realMarker?.remove();
|
||||||
realMarker = new ml.Marker({ color: '#0b3d2e' }).setLngLat([real.lon, real.lat]).addTo(map);
|
realMarker = new ml.Marker({ color: '#0b3d2e' }).setLngLat([real.lon, real.lat]).addTo(map);
|
||||||
|
// Le marqueur « tombe » avec un petit rebond (transform only, keyframes CSS).
|
||||||
|
if (!reducedMotion) realMarker.getElement().classList.add('tombe');
|
||||||
|
|
||||||
// Le marqueur du joueur « vole » vers la position réelle (sauf reduced-motion)
|
if (guess) {
|
||||||
if (guessMarker && guess) {
|
|
||||||
const from = { ...guess };
|
|
||||||
if (reducedMotion) {
|
|
||||||
guessMarker.setLngLat([real.lon, real.lat]);
|
|
||||||
} else {
|
|
||||||
const debut = performance.now();
|
|
||||||
const duree = 1400;
|
|
||||||
const step = (now: number) => {
|
|
||||||
if (!guessMarker) return;
|
|
||||||
const k = Math.min(1, (now - debut) / duree);
|
|
||||||
const e = 1 - Math.pow(1 - k, 3);
|
|
||||||
guessMarker.setLngLat([
|
|
||||||
from.lon + (real.lon - from.lon) * e,
|
|
||||||
from.lat + (real.lat - from.lat) * e
|
|
||||||
]);
|
|
||||||
if (k < 1) raf = requestAnimationFrame(step);
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
const bounds = new ml.LngLatBounds(
|
const bounds = new ml.LngLatBounds(
|
||||||
[Math.min(from.lon, real.lon), Math.min(from.lat, real.lat)],
|
[Math.min(guess.lon, real.lon), Math.min(guess.lat, real.lat)],
|
||||||
[Math.max(from.lon, real.lon), Math.max(from.lat, real.lat)]
|
[Math.max(guess.lon, real.lon), Math.max(guess.lat, real.lat)]
|
||||||
);
|
);
|
||||||
map.fitBounds(bounds, { padding: 80, duration: reducedMotion ? 0 : 900, maxZoom: 12 });
|
// Le panneau résultat vient de remplacer la photo : synchroniser la
|
||||||
|
// taille du canvas AVANT de calculer le cadrage, sinon il est faussé.
|
||||||
|
// maxZoom 15 (et non 12) : pour un guess proche du lieu, les deux
|
||||||
|
// marqueurs doivent rester distinguables (juger « à quel point on est bon »).
|
||||||
|
map.resize();
|
||||||
|
map.fitBounds(bounds, { padding: 90, duration: reducedMotion ? 0 : 900, maxZoom: 15 });
|
||||||
} else {
|
} else {
|
||||||
map.easeTo({ center: [real.lon, real.lat], zoom: 11, duration: reducedMotion ? 0 : 900 });
|
map.easeTo({ center: [real.lon, real.lat], zoom: 11, duration: reducedMotion ? 0 : 900 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nettoyerRevelation(): void {
|
function nettoyerRevelation(): void {
|
||||||
cancelAnimationFrame(raf);
|
annulerTraceLigne();
|
||||||
realMarker?.remove();
|
realMarker?.remove();
|
||||||
realMarker = null;
|
realMarker = null;
|
||||||
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: [] });
|
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: [] });
|
||||||
@@ -157,6 +207,7 @@
|
|||||||
guessMarker.on('dragend', () => {
|
guessMarker.on('dragend', () => {
|
||||||
const ll = guessMarker?.getLngLat();
|
const ll = guessMarker?.getLngLat();
|
||||||
if (ll) {
|
if (ll) {
|
||||||
|
pulseAttendu = true; // le déplacement au drag est aussi un placement
|
||||||
guess = { lat: ll.lat, lon: ll.lng };
|
guess = { lat: ll.lat, lon: ll.lng };
|
||||||
onguess?.(guess);
|
onguess?.(guess);
|
||||||
}
|
}
|
||||||
@@ -165,6 +216,17 @@
|
|||||||
guessMarker.setLngLat([guess.lon, guess.lat]);
|
guessMarker.setLngLat([guess.lon, guess.lat]);
|
||||||
}
|
}
|
||||||
guessMarker.setDraggable(interactive && !real);
|
guessMarker.setDraggable(interactive && !real);
|
||||||
|
if (pulseAttendu) {
|
||||||
|
pulseAttendu = false;
|
||||||
|
if (!reducedMotion) {
|
||||||
|
// Feedback de placement : pulse léger (redémarrage forcé si le
|
||||||
|
// marqueur bouge à nouveau pendant l'animation).
|
||||||
|
const el = guessMarker.getElement();
|
||||||
|
el.classList.remove('pulse');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('pulse');
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
guessMarker?.remove();
|
guessMarker?.remove();
|
||||||
guessMarker = null;
|
guessMarker = null;
|
||||||
@@ -216,11 +278,15 @@
|
|||||||
if (!map || !ml) return;
|
if (!map || !ml) return;
|
||||||
for (const m of explorerMarkers) m.remove();
|
for (const m of explorerMarkers) m.remove();
|
||||||
explorerMarkers = [];
|
explorerMarkers = [];
|
||||||
for (const info of markers) {
|
for (const [i, info] of markers.entries()) {
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
el.className = 'marqueur-explorer';
|
el.className = 'marqueur-explorer';
|
||||||
el.setAttribute('aria-label', info.label);
|
el.setAttribute('aria-label', info.label);
|
||||||
|
// Cascade syncopée au (re)filtrage : rythme 3+3+2 sur le premier
|
||||||
|
// cycle, plafonné à 960 ms au-delà (le catalogue peut être long).
|
||||||
|
// Sans effet en reduced-motion : l'animation est sous gate média.
|
||||||
|
el.style.animationDelay = `${Math.min(syncopatedDelay(i), 960)}ms`;
|
||||||
const mk = new ml.Marker({ element: el, color: '#175e70' })
|
const mk = new ml.Marker({ element: el, color: '#175e70' })
|
||||||
.setLngLat([info.lon, info.lat])
|
.setLngLat([info.lon, info.lat])
|
||||||
.addTo(map!);
|
.addTo(map!);
|
||||||
@@ -299,6 +365,7 @@
|
|||||||
map.addControl(new mod.NavigationControl({ showCompass: false }), 'top-left');
|
map.addControl(new mod.NavigationControl({ showCompass: false }), 'top-left');
|
||||||
map.on('click', (e) => {
|
map.on('click', (e) => {
|
||||||
if (!interactive || real) return;
|
if (!interactive || real) return;
|
||||||
|
pulseAttendu = true; // feedback au placement du marqueur
|
||||||
guess = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
guess = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
||||||
onguess?.(guess);
|
onguess?.(guess);
|
||||||
});
|
});
|
||||||
@@ -308,10 +375,16 @@
|
|||||||
syncExplorerMarkers();
|
syncExplorerMarkers();
|
||||||
if (real) dessinerRevelation();
|
if (real) dessinerRevelation();
|
||||||
});
|
});
|
||||||
|
// MapLibre ne détecte pas seul les changements de taille du conteneur
|
||||||
|
// (panneau résultat qui remplace la photo, rotation d'écran…).
|
||||||
|
observateurTaille = new ResizeObserver(() => map?.resize());
|
||||||
|
observateurTaille.observe(conteneur);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
detruit = true;
|
detruit = true;
|
||||||
cancelAnimationFrame(raf);
|
annulerTraceLigne();
|
||||||
|
observateurTaille?.disconnect();
|
||||||
|
observateurTaille = null;
|
||||||
map?.remove();
|
map?.remove();
|
||||||
map = null;
|
map = null;
|
||||||
};
|
};
|
||||||
@@ -319,7 +392,13 @@
|
|||||||
|
|
||||||
let ancienReal: Coordonnees | null = null;
|
let ancienReal: Coordonnees | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!map || !map.loaded()) return;
|
// Lecture inconditionnelle de `real` : au montage la carte n'existe pas
|
||||||
|
// encore, et un retour anticipé avant toute lecture réactive ferait que
|
||||||
|
// Svelte n'enregistre aucune dépendance — l'effet ne serait jamais relancé.
|
||||||
|
// (isStyleLoaded et non loaded : loaded() reste faux pendant le chargement
|
||||||
|
// des tuiles, ce qui ferait rater le dessin juste après un zoom/pan.)
|
||||||
|
void real;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
if (real && !ancienReal) dessinerRevelation();
|
if (real && !ancienReal) dessinerRevelation();
|
||||||
if (!real && ancienReal) nettoyerRevelation();
|
if (!real && ancienReal) nettoyerRevelation();
|
||||||
ancienReal = real;
|
ancienReal = real;
|
||||||
@@ -367,33 +446,39 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carte {
|
.carte {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
border-radius: var(--oki-radius);
|
border-radius: var(--oki-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile : les sauts sortent de la carte — barre horizontale SOUS la carte,
|
||||||
|
dimensionnée pour que les 4 régions tiennent sur 360px+ (scroll en repli),
|
||||||
|
jamais recouverte par l'attribution ni un overlay. */
|
||||||
.sauts {
|
.sauts {
|
||||||
position: absolute;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
flex-wrap: nowrap;
|
||||||
flex-wrap: wrap;
|
gap: 0.3rem;
|
||||||
justify-content: center;
|
overflow-x: auto;
|
||||||
padding: 0 0.5rem;
|
padding: 0.1rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saut {
|
.saut {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 48px; /* cible tactile */
|
||||||
background: rgba(255, 253, 248, 0.94);
|
background: rgba(255, 253, 248, 0.94);
|
||||||
color: var(--oki-vert);
|
color: var(--oki-vert);
|
||||||
border: 1px solid var(--oki-sable-fonce);
|
border: 1px solid var(--oki-sable-fonce);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.35rem 0.8rem;
|
padding: 0.3rem 0.7rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -404,6 +489,22 @@
|
|||||||
background: var(--oki-vert-doux);
|
background: var(--oki-vert-doux);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop : overlay bas-gauche, dégagé de l'attribution compacte (bas-droite). */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sauts {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
max-width: calc(100% - 5rem);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saut {
|
||||||
|
min-height: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
} }
|
||||||
|
|
||||||
:global(.marqueur-explorer) {
|
:global(.marqueur-explorer) {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@@ -418,4 +519,61 @@
|
|||||||
:global(.marqueur-explorer:focus-visible) {
|
:global(.marqueur-explorer:focus-visible) {
|
||||||
background: var(--oki-vert-clair);
|
background: var(--oki-vert-clair);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* — Motion des marqueurs : transform/opacity uniquement, gate no-preference
|
||||||
|
(les classes tombe/pulse ne sont posées qu'hors reduced-motion, et les
|
||||||
|
délais inline de la cascade explorer sont sans effet sans animation). — */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
/* Cascade syncopée d'entrée des marqueurs du catalogue explorer */
|
||||||
|
:global(.marqueur-explorer) {
|
||||||
|
animation: marqueur-pop var(--dur-mesure) var(--ease-ka) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le marqueur du lieu réel « tombe » avec un petit rebond */
|
||||||
|
:global(.maplibregl-marker.tombe > svg) {
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: marqueur-tombe var(--dur-mesure) var(--ease-ka);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse léger au placement du marqueur de guess */
|
||||||
|
:global(.maplibregl-marker.pulse > svg) {
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: marqueur-pulse var(--dur-mesure) var(--ease-ka);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marqueur-pop {
|
||||||
|
from {
|
||||||
|
transform: scale(0.6);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marqueur-tombe {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-34px) scale(0.85);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: translateY(3px) scale(1.03);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marqueur-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,5 +59,6 @@
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(transparent, rgba(11, 61, 46, 0.85));
|
background: linear-gradient(transparent, rgba(11, 61, 46, 0.85));
|
||||||
|
pointer-events: none; /* décoratif : ne bloque jamais les clics */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import WikiFiche from './WikiFiche.svelte';
|
import WikiFiche from './WikiFiche.svelte';
|
||||||
import { t } from '$lib/i18n/store.svelte';
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { compte } from '$lib/motion/countup';
|
||||||
import type { GuessResult } from '$lib/types';
|
import type { GuessResult } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,7 +18,11 @@
|
|||||||
<header class="score-bloc">
|
<header class="score-bloc">
|
||||||
<p class="bravo">{result.score > 0 ? t().micro.bravo : t().micro.kiteSaYe}</p>
|
<p class="bravo">{result.score > 0 ? t().micro.bravo : t().micro.kiteSaYe}</p>
|
||||||
<p class="score">
|
<p class="score">
|
||||||
<span class="valeur">{result.score}</span>
|
<!-- Count-up animé (aria-hidden) doublé de la valeur finale pour les
|
||||||
|
lecteurs d'écran : la section est aria-live, l'animation rAF
|
||||||
|
écrirait sinon une valeur par frame. -->
|
||||||
|
<span class="valeur" aria-hidden="true" use:compte={result.score}>{result.score}</span>
|
||||||
|
<span class="sr-only">{result.score}</span>
|
||||||
<span class="unite">{t().resultat.points}</span>
|
<span class="unite">{t().resultat.points}</span>
|
||||||
</p>
|
</p>
|
||||||
{#if result.distanceKm !== null}
|
{#if result.distanceKm !== null}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SITE_NAME, SITE_URL } from '$lib/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Titre de la page (sans le nom du site) — « JWE — … » est composé ici. */
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Chemin absolu de la route (ex. `/explorer`) — canonical + og:url. */
|
||||||
|
path: string;
|
||||||
|
/** URL absolue d'une image og (aucune émise si absente — pas d'image inventée). */
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, description, path, image }: Props = $props();
|
||||||
|
|
||||||
|
const titreComplet = $derived(`${SITE_NAME} — ${title}`);
|
||||||
|
const urlCanonique = $derived(`${SITE_URL}${path}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{titreComplet}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={urlCanonique} />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={urlCanonique} />
|
||||||
|
<meta property="og:title" content={titreComplet} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:locale" content="fr_FR" />
|
||||||
|
<meta property="og:site_name" content={SITE_NAME} />
|
||||||
|
{#if image}
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||||
|
<meta name="twitter:title" content={titreComplet} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
{#if image}
|
||||||
|
<meta name="twitter:image" content={image} />
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import type { Viewer } from 'mapillary-js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mapillaryId: string;
|
||||||
|
credit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mapillaryId, credit }: Props = $props();
|
||||||
|
|
||||||
|
let conteneur: HTMLDivElement;
|
||||||
|
let viewer: Viewer | null = null;
|
||||||
|
let etat = $state<'chargement' | 'pret' | 'erreur' | 'token'>('chargement');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let detruit = false;
|
||||||
|
// Lazy load : mapillary-js (JS + CSS) n'est chargé que sur l'écran de jeu street.
|
||||||
|
void (async () => {
|
||||||
|
const token = env.PUBLIC_MAPILLARY_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
etat = 'token';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [mod] = await Promise.all([
|
||||||
|
import('mapillary-js'),
|
||||||
|
import('mapillary-js/dist/mapillary.css')
|
||||||
|
]);
|
||||||
|
if (detruit) return;
|
||||||
|
// cover désactivé : la photo s'affiche directement, sans écran d'intro.
|
||||||
|
viewer = new mod.Viewer({
|
||||||
|
accessToken: token,
|
||||||
|
container: conteneur,
|
||||||
|
component: { cover: false }
|
||||||
|
});
|
||||||
|
// moveTo rejette si l'image a été retirée de Mapillary entre-temps.
|
||||||
|
await viewer.moveTo(mapillaryId);
|
||||||
|
if (detruit) return;
|
||||||
|
etat = 'pret';
|
||||||
|
} catch {
|
||||||
|
if (!detruit) etat = 'erreur';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
detruit = true;
|
||||||
|
viewer?.remove();
|
||||||
|
viewer = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<figure class="street-panel">
|
||||||
|
<div
|
||||||
|
bind:this={conteneur}
|
||||||
|
class="visionneuse"
|
||||||
|
role="application"
|
||||||
|
aria-label={t().jeu.streetAriaLabel}
|
||||||
|
></div>
|
||||||
|
{#if etat !== 'pret'}
|
||||||
|
<div class="voile">
|
||||||
|
<p>
|
||||||
|
{#if etat === 'chargement'}{t().jeu.streetChargement}
|
||||||
|
{:else if etat === 'token'}{t().jeu.streetToken}
|
||||||
|
{:else}{t().jeu.streetErreur}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if credit && etat === 'pret'}
|
||||||
|
<figcaption>{credit}</figcaption>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.street-panel {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--oki-vert);
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visionneuse {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voile {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--oki-blanc);
|
||||||
|
background: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(transparent, rgba(11, 61, 46, 0.85));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+14
-7
@@ -17,6 +17,8 @@ const fr = {
|
|||||||
lieuDesc: 'Une rue, un bord de mer, une forêt… devine où tu es.',
|
lieuDesc: 'Une rue, un bord de mer, une forêt… devine où tu es.',
|
||||||
defi: 'Défi 5 rounds',
|
defi: 'Défi 5 rounds',
|
||||||
defiDesc: '5 lieux d’affilée, monuments et lieux mélangés.',
|
defiDesc: '5 lieux d’affilée, monuments et lieux mélangés.',
|
||||||
|
street: 'An lari a',
|
||||||
|
streetDesc: 'Navigue dans une rue en photo 360° et devine la commune.',
|
||||||
explorer: 'Aprann',
|
explorer: 'Aprann',
|
||||||
explorerDesc: 'Explore la carte librement et lis les fiches Wikipédia. Sans score.'
|
explorerDesc: 'Explore la carte librement et lis les fiches Wikipédia. Sans score.'
|
||||||
},
|
},
|
||||||
@@ -25,7 +27,8 @@ const fr = {
|
|||||||
choixMode: 'Choisis ton mode',
|
choixMode: 'Choisis ton mode',
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
timer: 'Minuteur (60 s, bonus de score)',
|
timer: 'Minuteur (60 s, bonus de score)',
|
||||||
jouer: 'Jouer'
|
jouer: 'Jouer',
|
||||||
|
creerLienDefi: 'Créer un lien de défi'
|
||||||
},
|
},
|
||||||
jeu: {
|
jeu: {
|
||||||
charger: 'Chargement du lieu…',
|
charger: 'Chargement du lieu…',
|
||||||
@@ -39,6 +42,10 @@ const fr = {
|
|||||||
indicesRestants: 'indices restants',
|
indicesRestants: 'indices restants',
|
||||||
plusDIndice: 'Plus d’indice disponible',
|
plusDIndice: 'Plus d’indice disponible',
|
||||||
poserMarqueur: 'Clique sur la carte pour placer ton marqueur',
|
poserMarqueur: 'Clique sur la carte pour placer ton marqueur',
|
||||||
|
streetChargement: 'Chargement de la vue de rue…',
|
||||||
|
streetErreur: 'Impossible de charger la photo Mapillary. Réessaie ou passe au lieu suivant.',
|
||||||
|
streetToken: 'Visionneuse indisponible : configuration Mapillary manquante.',
|
||||||
|
streetAriaLabel: 'Vue de rue interactive. Flèches pour avancer, glisser pour regarder autour.',
|
||||||
round: 'Manche',
|
round: 'Manche',
|
||||||
sur: 'sur',
|
sur: 'sur',
|
||||||
difficulte: {
|
difficulte: {
|
||||||
@@ -49,8 +56,6 @@ const fr = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
resultat: {
|
resultat: {
|
||||||
bravo: 'Bravo !',
|
|
||||||
score: 'Score',
|
|
||||||
points: 'points',
|
points: 'points',
|
||||||
distance: 'Distance',
|
distance: 'Distance',
|
||||||
km: 'km',
|
km: 'km',
|
||||||
@@ -87,7 +92,9 @@ const fr = {
|
|||||||
telecharger: 'Télécharger l’image',
|
telecharger: 'Télécharger l’image',
|
||||||
taPosition: 'Ta position',
|
taPosition: 'Ta position',
|
||||||
positionReelle: 'Position réelle',
|
positionReelle: 'Position réelle',
|
||||||
manche: 'Manche'
|
titreSeed: 'Défi — seed',
|
||||||
|
copierLien: 'Copier le lien du défi',
|
||||||
|
lienCopie: 'Lien copié !'
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
titre: 'Aprann — Explorer',
|
titre: 'Aprann — Explorer',
|
||||||
@@ -100,12 +107,12 @@ const fr = {
|
|||||||
ariaLabel: 'Carte de jeu. Flèches pour déplacer le marqueur, Entrée pour valider.',
|
ariaLabel: 'Carte de jeu. Flèches pour déplacer le marqueur, Entrée pour valider.',
|
||||||
sautRegion: 'Aller à la région',
|
sautRegion: 'Aller à la région',
|
||||||
zoomIn: 'Zoomer',
|
zoomIn: 'Zoomer',
|
||||||
zoomOut: 'Dézoomer'
|
zoomOut: 'Dézoomer',
|
||||||
|
carteRegionsLabel: 'Carte des quatre régions : Guadeloupe, Martinique, Guyane, La Réunion'
|
||||||
},
|
},
|
||||||
micro: {
|
micro: {
|
||||||
bravo: 'Bravo !',
|
bravo: 'Bravo !',
|
||||||
kiteSaYe: 'Kité sa ye ?',
|
kiteSaYe: 'Kité sa ye ?'
|
||||||
pwenBlinde: 'Pwen blindé'
|
|
||||||
},
|
},
|
||||||
langue: {
|
langue: {
|
||||||
fr: 'Français',
|
fr: 'Français',
|
||||||
|
|||||||
+14
-7
@@ -19,6 +19,8 @@ const gcf: Dict = {
|
|||||||
lieuDesc: 'On lari, on bodlanmè, on fowé… chwé kote ou yé.',
|
lieuDesc: 'On lari, on bodlanmè, on fowé… chwé kote ou yé.',
|
||||||
defi: 'Défi 5 rounds',
|
defi: 'Défi 5 rounds',
|
||||||
defiDesc: '5 koté youn dèyè ròt, moniman épi plas mélanjé.',
|
defiDesc: '5 koté youn dèyè ròt, moniman épi plas mélanjé.',
|
||||||
|
street: 'An lari a',
|
||||||
|
streetDesc: 'Mache nan on lari an foto 360° é chwé komin-lan.',
|
||||||
explorer: 'Aprann',
|
explorer: 'Aprann',
|
||||||
explorerDesc: 'Flanné asou kat-la é li fich Wikipédia yo. San pwen.'
|
explorerDesc: 'Flanné asou kat-la é li fich Wikipédia yo. San pwen.'
|
||||||
},
|
},
|
||||||
@@ -27,7 +29,8 @@ const gcf: Dict = {
|
|||||||
choixMode: 'Chwazi jan ou vlé jwé',
|
choixMode: 'Chwazi jan ou vlé jwé',
|
||||||
options: 'Règlaj',
|
options: 'Règlaj',
|
||||||
timer: 'Kronomèt (60 s, bonus pwen)',
|
timer: 'Kronomèt (60 s, bonus pwen)',
|
||||||
jouer: 'Jwé'
|
jouer: 'Jwé',
|
||||||
|
creerLienDefi: 'Réyé on lyen défi'
|
||||||
},
|
},
|
||||||
jeu: {
|
jeu: {
|
||||||
charger: 'Nou ka chajé koté-la…',
|
charger: 'Nou ka chajé koté-la…',
|
||||||
@@ -41,6 +44,10 @@ const gcf: Dict = {
|
|||||||
indicesRestants: 'endis ki rété',
|
indicesRestants: 'endis ki rété',
|
||||||
plusDIndice: 'Pa ni endis ankò',
|
plusDIndice: 'Pa ni endis ankò',
|
||||||
poserMarqueur: 'Kwiké asou kat-la pou mété makò-a',
|
poserMarqueur: 'Kwiké asou kat-la pou mété makò-a',
|
||||||
|
streetChargement: 'Nou ka chajé vi lari-a…',
|
||||||
|
streetErreur: 'Nou pa rivé chajé foto Mapillary-la. Éséyé ankò oben pasé o koté swivan.',
|
||||||
|
streetToken: 'Vi lari-a pa disponib : konfigirasyon Mapillary-la manké.',
|
||||||
|
streetAriaLabel: 'Vi lari-a. Flèch yo pou avansé, glissé pou gadté toutò.',
|
||||||
round: 'Mannèk',
|
round: 'Mannèk',
|
||||||
sur: 'asou',
|
sur: 'asou',
|
||||||
difficulte: {
|
difficulte: {
|
||||||
@@ -51,8 +58,6 @@ const gcf: Dict = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
resultat: {
|
resultat: {
|
||||||
bravo: 'Bravo !',
|
|
||||||
score: 'Pwen',
|
|
||||||
points: 'pwen',
|
points: 'pwen',
|
||||||
distance: 'Distans',
|
distance: 'Distans',
|
||||||
km: 'km',
|
km: 'km',
|
||||||
@@ -89,7 +94,9 @@ const gcf: Dict = {
|
|||||||
telecharger: 'Téléchajé foto-a',
|
telecharger: 'Téléchajé foto-a',
|
||||||
taPosition: 'Pozisyon ou',
|
taPosition: 'Pozisyon ou',
|
||||||
positionReelle: 'Rèl pozisyon-an',
|
positionReelle: 'Rèl pozisyon-an',
|
||||||
manche: 'Mannèk'
|
titreSeed: 'Défi — seed',
|
||||||
|
copierLien: 'Kopié lyen défi-a',
|
||||||
|
lienCopie: 'Lyen kopié !'
|
||||||
},
|
},
|
||||||
explorer: {
|
explorer: {
|
||||||
titre: 'Aprann — Flanné',
|
titre: 'Aprann — Flanné',
|
||||||
@@ -102,12 +109,12 @@ const gcf: Dict = {
|
|||||||
ariaLabel: 'Kat jwé-a. Flèch yo pou déplasé makò-a, Antré pou validé.',
|
ariaLabel: 'Kat jwé-a. Flèch yo pou déplasé makò-a, Antré pou validé.',
|
||||||
sautRegion: 'Alé o péyi',
|
sautRegion: 'Alé o péyi',
|
||||||
zoomIn: 'Zoumé',
|
zoomIn: 'Zoumé',
|
||||||
zoomOut: 'Dézoumé'
|
zoomOut: 'Dézoumé',
|
||||||
|
carteRegionsLabel: 'Kat 4 péyi yo : Gwadloup, Matinik, Giyan, Léryinyon'
|
||||||
},
|
},
|
||||||
micro: {
|
micro: {
|
||||||
bravo: 'Bravo !',
|
bravo: 'Bravo !',
|
||||||
kiteSaYe: 'Kité sa ye ?',
|
kiteSaYe: 'Kité sa ye ?'
|
||||||
pwenBlinde: 'Pwen blindé'
|
|
||||||
},
|
},
|
||||||
langue: {
|
langue: {
|
||||||
fr: 'Français',
|
fr: 'Français',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,56 @@
|
|||||||
|
import { dureeMs, easeSyncope, prefersReducedMotion } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action `use:compte={cible}` — count-up de 0 (ou de la valeur précédente)
|
||||||
|
* vers la cible, en `--dur-compte` avec easing syncopé. L'écriture se fait
|
||||||
|
* directement dans le DOM via rAF : jamais d'état réactif par frame.
|
||||||
|
*
|
||||||
|
* Reduced-motion : la valeur finale est écrite immédiatement. Le markup doit
|
||||||
|
* contenir la valeur finale (état SSR / premier rendu) et être doublé d'un
|
||||||
|
* équivalent non animé pour les lecteurs d'écran (le span animé porte
|
||||||
|
* aria-hidden="true" chez l'appelant).
|
||||||
|
*/
|
||||||
|
export function compte(node: HTMLElement, cible: number) {
|
||||||
|
let raf: number | null = null;
|
||||||
|
let affiche = 0;
|
||||||
|
|
||||||
|
function ecrire(valeur: number): void {
|
||||||
|
affiche = valeur;
|
||||||
|
node.textContent = String(valeur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jouer(vers: number): void {
|
||||||
|
if (raf !== null) {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = null;
|
||||||
|
}
|
||||||
|
const depuis = affiche;
|
||||||
|
const duree = dureeMs('--dur-compte');
|
||||||
|
if (prefersReducedMotion() || vers === depuis || duree <= 0) {
|
||||||
|
ecrire(vers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t0 = performance.now();
|
||||||
|
const tick = (t: number): void => {
|
||||||
|
const p = Math.min(1, (t - t0) / duree);
|
||||||
|
ecrire(Math.round(depuis + (vers - depuis) * easeSyncope(p)));
|
||||||
|
if (p < 1) {
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
raf = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
jouer(cible);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(nouvelleCible: number) {
|
||||||
|
jouer(nouvelleCible);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
if (raf !== null) cancelAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Action `use:reveal` — ajoute la classe `in` à l'entrée dans le viewport.
|
||||||
|
*
|
||||||
|
* Variante SSR de la brique o-k-i.net : l'état caché initial est rendu côté
|
||||||
|
* serveur (`data-reveal` + `--d` directement dans le markup), et n'est actif
|
||||||
|
* que sous `html.js` (classe posée par le script de app.html) ET
|
||||||
|
* `prefers-reduced-motion: no-preference` (media query dans oki.css). Sans JS
|
||||||
|
* ou en reduced-motion, le contenu est intégralement visible.
|
||||||
|
*
|
||||||
|
* @param actif false = pas d'observation (ex. CarteRegions sans cascade)
|
||||||
|
*/
|
||||||
|
export function reveal(node: Element, actif: boolean = true) {
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
function observerNoeud(actifCourant: boolean): void {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
if (!actifCourant) return;
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
node.classList.add('in');
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.15, rootMargin: '0px 0px -5% 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
observerNoeud(actif);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(nouvelActif: boolean) {
|
||||||
|
observerNoeud(nouvelActif);
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
observer?.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
/** Gate unique prefers-reduced-motion (playbook §6). */
|
||||||
|
export function prefersReducedMotion(): boolean {
|
||||||
|
return browser && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stagger syncopé de marque OKI (3+3+2) — charte §3.
|
||||||
|
* Délais en ms pour l'index donné (cycle de 8 éléments).
|
||||||
|
*/
|
||||||
|
const SYNCOPATED_DELAYS = [0, 120, 300, 360, 600, 660, 900, 960];
|
||||||
|
|
||||||
|
export function syncopatedDelay(index: number): number {
|
||||||
|
const cycle = Math.floor(index / 8);
|
||||||
|
return SYNCOPATED_DELAYS[index % 8] + cycle * 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit une durée depuis les tokens CSS de oki.css (--dur-*) — les valeurs ne
|
||||||
|
* sont jamais dupliquées côté appelant. Client uniquement (les transitions
|
||||||
|
* Svelte et les rAF ne courent de toute façon pas côté serveur).
|
||||||
|
* Retourne 0 si le token est absent : l'animation se replie alors en
|
||||||
|
* application immédiate, sans valeur de secours hardcodée.
|
||||||
|
*/
|
||||||
|
export function dureeMs(nom: string): number {
|
||||||
|
if (!browser) return 0;
|
||||||
|
const brut = getComputedStyle(document.documentElement).getPropertyValue(nom).trim();
|
||||||
|
if (brut.endsWith('ms')) return Number.parseFloat(brut) || 0;
|
||||||
|
if (brut.endsWith('s')) return (Number.parseFloat(brut) || 0) * 1000;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cubic-bezier(0.22, 1, 0.36, 1) — --ease-ka : attaque franche, longue tenue. */
|
||||||
|
export function easeKa(x: number): number {
|
||||||
|
return 1 - Math.pow(1 - x, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cubic-bezier(0.65, 0, 0.35, 1) — --ease-syncope : symétrique, urgent. */
|
||||||
|
export function easeSyncope(x: number): number {
|
||||||
|
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { fade, fly, type FadeParams, type FlyParams } from 'svelte/transition';
|
||||||
|
import { dureeMs, prefersReducedMotion } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transitions d'état du jeu — enrobent les transitions natives Svelte avec :
|
||||||
|
* 1. des durées lues depuis les tokens CSS (--dur-*, jamais dupliquées) ;
|
||||||
|
* 2. la gate prefers-reduced-motion (durée 0 → état final immédiat).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fondu simple à la durée `--dur-mesure`. */
|
||||||
|
export function fonduMesure(node: Element, params: FadeParams = {}) {
|
||||||
|
if (prefersReducedMotion()) return fade(node, { ...params, duration: 0 });
|
||||||
|
return fade(node, { duration: dureeMs('--dur-mesure'), ...params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Montée courte (translateY + fondu) à la durée `--dur-mesure`. */
|
||||||
|
export function monteeMesure(node: Element, params: FlyParams = {}) {
|
||||||
|
if (prefersReducedMotion()) return fly(node, { ...params, duration: 0, y: 0 });
|
||||||
|
return fly(node, { duration: dureeMs('--dur-mesure'), y: 14, ...params });
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { defi } from '$lib/stores/defi.svelte';
|
||||||
|
|
||||||
|
/** Génère une image canvas téléchargeable : score total + manches du défi. */
|
||||||
|
export 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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "st-01",
|
||||||
|
"nom": "Rue Laurence Valmy, Pointe-à-Pitre",
|
||||||
|
"nom_creole": "Lari Pointe-à-Pitre",
|
||||||
|
"region": "GUADELOUPE",
|
||||||
|
"commune": "Pointe-à-Pitre",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 16.244455939903,
|
||||||
|
"lon": -61.530342688698
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "2941939632687980",
|
||||||
|
"credit": {
|
||||||
|
"username": "cartophyl",
|
||||||
|
"captured_at": "2015-07-07"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q335322",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île papillon, aile droite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Port de commerce principal de l’archipel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 2.78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-02",
|
||||||
|
"nom": "Rue Achille René Boisneuf, Les Abymes",
|
||||||
|
"nom_creole": "Lari Les Abymes",
|
||||||
|
"region": "GUADELOUPE",
|
||||||
|
"commune": "Les Abymes",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 16.269078645766,
|
||||||
|
"lon": -61.506631441162
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "306781020957650",
|
||||||
|
"credit": {
|
||||||
|
"username": "ratzillas",
|
||||||
|
"captured_at": "2015-07-17"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q13636",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île papillon, aile droite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Commune la plus peuplée de l’archipel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 70.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-03",
|
||||||
|
"nom": "Place Renovation le Bourg, Le Gosier",
|
||||||
|
"nom_creole": "Lari Le Gosier",
|
||||||
|
"region": "GUADELOUPE",
|
||||||
|
"commune": "Le Gosier",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 16.205608856053,
|
||||||
|
"lon": -61.492154911837
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "919062132219342",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2016-01-02"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q918874",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île papillon, aile droite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Station balnéaire au sud de la Grande-Terre"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 45.75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-04",
|
||||||
|
"nom": "Rue Emmanuel Robinet, Sainte-Anne",
|
||||||
|
"nom_creole": "Lari Sainte-Anne",
|
||||||
|
"region": "GUADELOUPE",
|
||||||
|
"commune": "Sainte-Anne",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 16.223896372356,
|
||||||
|
"lon": -61.384172350192
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "956408765176680",
|
||||||
|
"credit": {
|
||||||
|
"username": "ratzillas",
|
||||||
|
"captured_at": "2019-07-15"
|
||||||
|
},
|
||||||
|
"is_pano": true,
|
||||||
|
"wikidata_id": "Q386243",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île papillon, aile droite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Plages au sud de la Grande-Terre"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 80.96
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-05",
|
||||||
|
"nom": "Route des Esclaves, Basse-Terre",
|
||||||
|
"nom_creole": "Lari Basse-Terre",
|
||||||
|
"region": "GUADELOUPE",
|
||||||
|
"commune": "Basse-Terre",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 15.992959593433,
|
||||||
|
"lon": -61.730437544596
|
||||||
|
},
|
||||||
|
"difficulte": "EXPERT",
|
||||||
|
"mapillary_id": "212605717097536",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2016-01-04"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q167024",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île papillon, aile gauche"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Chef-lieu au pied du volcan"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 4.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-06",
|
||||||
|
"nom": "Rue Victor Sévère, Fort-de-France",
|
||||||
|
"nom_creole": "Lari Fort-de-France",
|
||||||
|
"region": "MARTINIQUE",
|
||||||
|
"commune": "Fort-de-France",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 14.60615582786,
|
||||||
|
"lon": -61.069885806689
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "152801196814796",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2015-12-31"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q81621",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île aux fleurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Chef-lieu, sur une grande baie"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 44.04
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-07",
|
||||||
|
"nom": "Rue des Barrières, Le Lamentin",
|
||||||
|
"nom_creole": "Lari Le Lamentin",
|
||||||
|
"region": "MARTINIQUE",
|
||||||
|
"commune": "Le Lamentin",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 14.615969225175,
|
||||||
|
"lon": -60.999773265489
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "1120779875836768",
|
||||||
|
"credit": {
|
||||||
|
"username": "Caramel",
|
||||||
|
"captured_at": "2024-04-29"
|
||||||
|
},
|
||||||
|
"is_pano": true,
|
||||||
|
"wikidata_id": "Q947069",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île aux fleurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Banlieue du chef-lieu, zone aéroportuaire"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 62.73
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-08",
|
||||||
|
"nom": "Rue Gabriel Péri, Saint-Pierre",
|
||||||
|
"nom_creole": "Lari Saint-Pierre",
|
||||||
|
"region": "MARTINIQUE",
|
||||||
|
"commune": "Saint-Pierre",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 14.738699893002,
|
||||||
|
"lon": -61.176412948112
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "1299527321706804",
|
||||||
|
"credit": {
|
||||||
|
"username": "jeremylavarenne",
|
||||||
|
"captured_at": "2025-08-31"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q589980",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île aux fleurs, côte caraïbe nord"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Ville détruite par une éruption en 1902"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 38.69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-09",
|
||||||
|
"nom": "Rue Florent Holo, Le François",
|
||||||
|
"nom_creole": "Lari Le François",
|
||||||
|
"region": "MARTINIQUE",
|
||||||
|
"commune": "Le François",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 14.615696752255,
|
||||||
|
"lon": -60.902520891662
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "4102521129805870",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2016-01-01"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q282944",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île aux fleurs, côte atlantique"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Bourg face aux îlets du sud"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 54.43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-10",
|
||||||
|
"nom": "Passage des Fleurs, Sainte-Marie",
|
||||||
|
"nom_creole": "Lari Sainte-Marie",
|
||||||
|
"region": "MARTINIQUE",
|
||||||
|
"commune": "Sainte-Marie",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 14.783766654906,
|
||||||
|
"lon": -60.995666311379
|
||||||
|
},
|
||||||
|
"difficulte": "EXPERT",
|
||||||
|
"mapillary_id": "131479329019761",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2015-12-31"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q1758414",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île aux fleurs, côte atlantique nord"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Musée de la banane et rhum"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 44.65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-11",
|
||||||
|
"nom": "Rue Francois Arago, Cayenne",
|
||||||
|
"nom_creole": "Lari Cayenne",
|
||||||
|
"region": "GUYANE",
|
||||||
|
"commune": "Cayenne",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 4.9388185867966,
|
||||||
|
"lon": -52.331808101487
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "259270559222687",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2016-08-18"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q44401",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Terre de forêt amazonienne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Chef-lieu, sur l’île du même nom"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 25.52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-12",
|
||||||
|
"nom": "Rue de l’Odéon, Kourou",
|
||||||
|
"nom_creole": "Lari Kourou",
|
||||||
|
"region": "GUYANE",
|
||||||
|
"commune": "Kourou",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 5.1615176393394,
|
||||||
|
"lon": -52.643325658226
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "198127629404270",
|
||||||
|
"credit": {
|
||||||
|
"username": "hacp",
|
||||||
|
"captured_at": "2022-09-07"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q273312",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Terre de forêt amazonienne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Ville des fusées"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 2289.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-13",
|
||||||
|
"nom": "Avenue Albert Sarraut, Saint-Laurent-du-Maroni",
|
||||||
|
"nom_creole": "Lari Saint-Laurent-du-Maroni",
|
||||||
|
"region": "GUYANE",
|
||||||
|
"commune": "Saint-Laurent-du-Maroni",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 5.5060375294565,
|
||||||
|
"lon": -54.027135700884
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "147142287390967",
|
||||||
|
"credit": {
|
||||||
|
"username": "pma",
|
||||||
|
"captured_at": "2021-01-18"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q319959",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Terre de forêt amazonienne, à l’ouest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Ville du fleuve Maroni, ancien bagne"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 4320.77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-14",
|
||||||
|
"nom": "Rue Maurice BOCS, Matoury",
|
||||||
|
"nom_creole": "Lari Matoury",
|
||||||
|
"region": "GUYANE",
|
||||||
|
"commune": "Matoury",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 4.8496318387901,
|
||||||
|
"lon": -52.328783153736
|
||||||
|
},
|
||||||
|
"difficulte": "EXPERT",
|
||||||
|
"mapillary_id": "582210316504280",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2016-08-18"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q816739",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Terre de forêt amazonienne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Banlieue sud du chef-lieu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 146.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-15",
|
||||||
|
"nom": "Rue de la CANNE À SUCRE, Remire-Montjoly",
|
||||||
|
"nom_creole": "Lari Remire-Montjoly",
|
||||||
|
"region": "GUYANE",
|
||||||
|
"commune": "Remire-Montjoly",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": 4.9037937340773,
|
||||||
|
"lon": -52.273335996076
|
||||||
|
},
|
||||||
|
"difficulte": "EXPERT",
|
||||||
|
"mapillary_id": "247810083801809",
|
||||||
|
"credit": {
|
||||||
|
"username": "kaartcam",
|
||||||
|
"captured_at": "2016-08-18"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q996222",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Terre de forêt amazonienne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Plages à l’est du chef-lieu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 46.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-16",
|
||||||
|
"nom": "Rue de Paris, Saint-Denis",
|
||||||
|
"nom_creole": "Lari Saint-Denis",
|
||||||
|
"region": "REUNION",
|
||||||
|
"commune": "Saint-Denis",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": -20.8832861,
|
||||||
|
"lon": 55.4501
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "2262788723930767",
|
||||||
|
"credit": {
|
||||||
|
"username": "AllWays360",
|
||||||
|
"captured_at": "2022-10-06"
|
||||||
|
},
|
||||||
|
"is_pano": true,
|
||||||
|
"wikidata_id": "Q47045",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île intense de l’océan Indien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Chef-lieu, au nord"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 142.33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-17",
|
||||||
|
"nom": "Rue du Cimetiere, Saint-Pierre",
|
||||||
|
"nom_creole": "Lari Saint-Pierre",
|
||||||
|
"region": "REUNION",
|
||||||
|
"commune": "Saint-Pierre",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": -21.338988428747,
|
||||||
|
"lon": 55.478221620359
|
||||||
|
},
|
||||||
|
"difficulte": "MOYEN",
|
||||||
|
"mapillary_id": "143202321798356",
|
||||||
|
"credit": {
|
||||||
|
"username": "sigstpierre",
|
||||||
|
"captured_at": "2022-11-09"
|
||||||
|
},
|
||||||
|
"is_pano": true,
|
||||||
|
"wikidata_id": "Q249884",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île intense de l’océan Indien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Capitale du sud, front de mer animé"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 96.82
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-18",
|
||||||
|
"nom": "Rue Aristide Briand, Le Tampon",
|
||||||
|
"nom_creole": "Lari Le Tampon",
|
||||||
|
"region": "REUNION",
|
||||||
|
"commune": "Le Tampon",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": -21.277675635385,
|
||||||
|
"lon": 55.514338556402
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "1382811445417165",
|
||||||
|
"credit": {
|
||||||
|
"username": "vandecasteele",
|
||||||
|
"captured_at": "2019-12-16"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q13648",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île intense de l’océan Indien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Commune des hauts, dans le sud"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 182.09
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-19",
|
||||||
|
"nom": "Rue Elie Eudor, Saint-Paul",
|
||||||
|
"nom_creole": "Lari Saint-Paul",
|
||||||
|
"region": "REUNION",
|
||||||
|
"commune": "Saint-Paul",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": -21.008621906549,
|
||||||
|
"lon": 55.271355918998
|
||||||
|
},
|
||||||
|
"difficulte": "DIFFICILE",
|
||||||
|
"mapillary_id": "928324514646684",
|
||||||
|
"credit": {
|
||||||
|
"username": "sogefi",
|
||||||
|
"captured_at": "2018-10-14"
|
||||||
|
},
|
||||||
|
"is_pano": false,
|
||||||
|
"wikidata_id": "Q316887",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île intense de l’océan Indien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Côte ouest, premier site de peuplement"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 241.64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "st-20",
|
||||||
|
"nom": "Ccal de la Republique, Saint-André",
|
||||||
|
"nom_creole": "Lari Saint-André",
|
||||||
|
"region": "REUNION",
|
||||||
|
"commune": "Saint-André",
|
||||||
|
"coordonnees": {
|
||||||
|
"lat": -20.962268395233,
|
||||||
|
"lon": 55.651364657007
|
||||||
|
},
|
||||||
|
"difficulte": "EXPERT",
|
||||||
|
"mapillary_id": "519625869420978",
|
||||||
|
"credit": {
|
||||||
|
"username": "vandecasteele",
|
||||||
|
"captured_at": "2019-06-28"
|
||||||
|
},
|
||||||
|
"is_pano": true,
|
||||||
|
"wikidata_id": "Q1649370",
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"niveau": 1,
|
||||||
|
"texte": "Île intense de l’océan Indien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"niveau": 2,
|
||||||
|
"texte": "Côte est, pays de la canne"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aire_commune_km2": 54.66
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import rawLieux from './data/lieux.json';
|
import rawLieux from './data/lieux.json';
|
||||||
|
import rawStreet from './data/street.json';
|
||||||
import type {
|
import type {
|
||||||
Categorie,
|
Categorie,
|
||||||
Difficulte,
|
Difficulte,
|
||||||
@@ -6,10 +7,15 @@ import type {
|
|||||||
GuessResult,
|
GuessResult,
|
||||||
Lieu,
|
Lieu,
|
||||||
Region,
|
Region,
|
||||||
RoundPlace
|
RoundPlace,
|
||||||
|
StreetLieu
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
const lieux = rawLieux as Lieu[];
|
const lieux = rawLieux as Lieu[];
|
||||||
|
const street = rawStreet as StreetLieu[];
|
||||||
|
|
||||||
|
/** Lieu « scorable » : les deux corpus partagent les champs du scoring. */
|
||||||
|
type Scoreable = Lieu | StreetLieu;
|
||||||
|
|
||||||
const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
||||||
const CATEGORIES: Categorie[] = ['MONUMENT', 'LIEU'];
|
const CATEGORIES: Categorie[] = ['MONUMENT', 'LIEU'];
|
||||||
@@ -43,7 +49,13 @@ function parseCategorie(value: string | null): Categorie | null {
|
|||||||
return value && CATEGORIES.includes(value as Categorie) ? (value as Categorie) : null;
|
return value && CATEGORIES.includes(value as Categorie) ? (value as Categorie) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLieuById(id: string): Lieu | undefined {
|
/** Recherche dans les deux corpus (photos classiques + street Mapillary). */
|
||||||
|
export function getLieuById(id: string): Scoreable | undefined {
|
||||||
|
return lieux.find((l) => l.id === id) ?? street.find((l) => l.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche limitée au corpus photo classique (défi, proxy photo). */
|
||||||
|
export function getLieuPhotoById(id: string): Lieu | undefined {
|
||||||
return lieux.find((l) => l.id === id);
|
return lieux.find((l) => l.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +86,28 @@ export function getRandomLieu(options: {
|
|||||||
return pool[Math.floor(Math.random() * pool.length)];
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sélection aléatoire d'un lieu street (mode « An lari a », corpus Mapillary). */
|
||||||
|
export function getRandomStreet(options: {
|
||||||
|
region?: string | null;
|
||||||
|
exclude?: string | null;
|
||||||
|
}): StreetLieu | null {
|
||||||
|
const region = parseRegion(options.region ?? null);
|
||||||
|
const excluded = new Set(
|
||||||
|
(options.exclude ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
let pool = street.filter((l) => !excluded.has(l.id));
|
||||||
|
if (region) pool = pool.filter((l) => l.region === region);
|
||||||
|
// Si tout a été exclu, on réessaie sans les exclusions (le jeu doit continuer).
|
||||||
|
if (pool.length === 0) {
|
||||||
|
pool = region ? street.filter((l) => l.region === region) : street;
|
||||||
|
}
|
||||||
|
if (pool.length === 0) return null;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
/** Largeurs (px) servies par le proxy photo /api/photo/[id]?w=. */
|
/** Largeurs (px) servies par le proxy photo /api/photo/[id]?w=. */
|
||||||
export const PHOTO_LARGEURS = [400, 800, 1200] as const;
|
export const PHOTO_LARGEURS = [400, 800, 1200] as const;
|
||||||
|
|
||||||
@@ -103,6 +137,27 @@ export function sanitizeRound(lieu: Lieu): RoundPlace {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anti-triche, mode street : ne garde que les champs publics.
|
||||||
|
* Seul `mapillaryId` part au client (la visionneuse doit bien appeler Mapillary) ;
|
||||||
|
* coords/commune/région/wikidata_id ne partent jamais avant le guess.
|
||||||
|
* Limitation documentée (architecture.md §10) : l'image_key est publique côté
|
||||||
|
* Mapillary, un joueur techniquement équipé peut retrouver la position — même
|
||||||
|
* propriété que GeoGuessr/Street View, acceptable en solo sans leaderboard.
|
||||||
|
*/
|
||||||
|
export function sanitizeRoundStreet(lieu: StreetLieu): RoundPlace {
|
||||||
|
return {
|
||||||
|
id: lieu.id,
|
||||||
|
nom: lieu.nom,
|
||||||
|
nom_creole: lieu.nom_creole,
|
||||||
|
categorie: 'LIEU',
|
||||||
|
difficulte: lieu.difficulte,
|
||||||
|
mapillaryId: lieu.mapillary_id,
|
||||||
|
credit: `© ${lieu.credit.username} / Mapillary (CC BY-SA 4.0)`,
|
||||||
|
indices: lieu.indices
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rayon effectif (km) de la courbe de score (audit A1).
|
* Rayon effectif (km) de la courbe de score (audit A1).
|
||||||
* Les communes guyanaises sont immenses (Saint-Laurent-du-Maroni ≈ 4 321 km²) :
|
* Les communes guyanaises sont immenses (Saint-Laurent-du-Maroni ≈ 4 321 km²) :
|
||||||
@@ -111,7 +166,7 @@ export function sanitizeRound(lieu: Lieu): RoundPlace {
|
|||||||
* au moins celui du disque de même aire que la commune : sqrt(aire / π).
|
* au moins celui du disque de même aire que la commune : sqrt(aire / π).
|
||||||
* Champ optionnel : sans lui, on retombe sur le rayon de difficulté.
|
* Champ optionnel : sans lui, on retombe sur le rayon de difficulté.
|
||||||
*/
|
*/
|
||||||
export function rayonEffectifKm(lieu: Lieu): number {
|
export function rayonEffectifKm(lieu: Scoreable): number {
|
||||||
const base = RAYONS[lieu.difficulte];
|
const base = RAYONS[lieu.difficulte];
|
||||||
const aire = lieu.aire_commune_km2;
|
const aire = lieu.aire_commune_km2;
|
||||||
if (typeof aire === 'number' && Number.isFinite(aire) && aire > 0) {
|
if (typeof aire === 'number' && Number.isFinite(aire) && aire > 0) {
|
||||||
@@ -126,7 +181,7 @@ export function rayonEffectifKm(lieu: Lieu): number {
|
|||||||
* bonus temps ≤ +20 % ; total plafonné à 5000.
|
* bonus temps ≤ +20 % ; total plafonné à 5000.
|
||||||
*/
|
*/
|
||||||
export function scoreGuess(
|
export function scoreGuess(
|
||||||
lieu: Lieu,
|
lieu: Scoreable,
|
||||||
guess: { lat: number; lon: number },
|
guess: { lat: number; lon: number },
|
||||||
hintsUsed: number,
|
hintsUsed: number,
|
||||||
timeMs?: number
|
timeMs?: number
|
||||||
@@ -154,7 +209,7 @@ export function scoreGuess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Réponse « Passer » : la vérité est révélée, score 0, pas de distance. */
|
/** Réponse « Passer » : la vérité est révélée, score 0, pas de distance. */
|
||||||
export function skipResult(lieu: Lieu): GuessResult {
|
export function skipResult(lieu: Scoreable): GuessResult {
|
||||||
return {
|
return {
|
||||||
distanceKm: null,
|
distanceKm: null,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Constantes du site — domaine public de production (cf. jwe_ynh/manifest.toml). */
|
||||||
|
export const SITE_URL = 'https://jwe.o-k-i.net';
|
||||||
|
export const SITE_NAME = 'JWE';
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/* Styles partagés des écrans de jeu : /jeu/[mode] et /defi/[seed].
|
||||||
|
Layout « app » verrouillé au viewport (100dvh via +layout) : la page ne
|
||||||
|
scrolle jamais, seuls les panneaux internes défilent. Mobile-first. */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Split photo | carte — */
|
||||||
|
.split {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile : la photo s'adapte (plafond ~42dvh), la carte garde au minimum
|
||||||
|
35dvh utiles + la barre de sauts (~3,5rem). */
|
||||||
|
.panneau-photo {
|
||||||
|
position: relative; /* ancre du panneau d'indices */
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 5rem;
|
||||||
|
max-height: 42dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau-carte {
|
||||||
|
position: relative; /* ancre de la puce de statut et de l'aide */
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: calc(35dvh + 3.5rem);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau-carte > * {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* État résultat : le panneau photo devient une fiche scrollable interne. */
|
||||||
|
.split.resultat .panneau-photo {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split.resultat .panneau-carte {
|
||||||
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultat-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Puce de statut unique (manche, seed, minuteur) : haut-droite, une ligne.
|
||||||
|
Aucun élément absolu au-dessus des zones interactives : les badges sont
|
||||||
|
décoratifs, les clics les traversent. — */
|
||||||
|
.statut {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-width: calc(100% - 1rem);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-manche {
|
||||||
|
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;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seed visible mais discrète : les joueurs vérifient qu'ils jouent le même défi */
|
||||||
|
.badge-seed {
|
||||||
|
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;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-timer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Indices : panneau scrollable interne, en surimpression de la photo
|
||||||
|
(zone non interactive) — jamais au-dessus de la carte. — */
|
||||||
|
.indices {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
bottom: 0.4rem;
|
||||||
|
z-index: 4;
|
||||||
|
max-height: calc(100% - 0.8rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: rgba(255, 253, 248, 0.96);
|
||||||
|
border-radius: var(--oki-radius-petit);
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 « poser le marqueur » : pastille décorative sur la carte, les clics
|
||||||
|
la traversent (pointer-events: none). Sur mobile elle flotte au bas du
|
||||||
|
canevas, juste au-dessus de la barre de sauts (~3,65rem) ; sur desktop elle
|
||||||
|
est décalée à droite pour ne pas chevaucher les sauts en overlay bas-gauche. — */
|
||||||
|
.aide-carte {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 3.9rem; /* 48px de sauts + espacements, arrondi au-dessus */
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 3;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
max-width: calc(100% - 5rem);
|
||||||
|
background: rgba(255, 253, 248, 0.92);
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Barre d'actions : en bas du flux (la page ne scrolle pas), opaque,
|
||||||
|
au-dessus de la carte grâce au z-index explicite. — */
|
||||||
|
.barre-actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.8rem calc(0.6rem + env(safe-area-inset-bottom));
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
border-top: 1px solid var(--oki-sable-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barre-actions .btn {
|
||||||
|
min-height: 48px; /* cible tactile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-secondaires {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-secondaires .btn {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalite {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valider {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Écrans très étroits : tout doit tenir sur une ligne. */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.actions-secondaires .btn {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalite {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valider {
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paysage / écrans très bas : on relâche les planchers pour éviter le rognage. */
|
||||||
|
@media (max-height: 540px) {
|
||||||
|
.panneau-photo {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
max-height: 34dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau-carte {
|
||||||
|
min-height: 11rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop : split 50/50 qui remplit exactement la hauteur disponible. */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.split {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau-photo,
|
||||||
|
.split.resultat .panneau-photo {
|
||||||
|
flex: 1 1 50%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau-carte,
|
||||||
|
.split.resultat .panneau-carte {
|
||||||
|
flex: 1 1 50%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aide-carte {
|
||||||
|
left: auto;
|
||||||
|
right: 0.8rem;
|
||||||
|
bottom: 3.4rem; /* au-dessus de la rangée de sauts en overlay bas-gauche */
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barre-actions {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Écran bilan du défi : compact, scroll interne en dernier recours. — */
|
||||||
|
.bilan {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan h1 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filet drapeau du bilan : 3 segments francs aux couleurs OKI qui se
|
||||||
|
dessinent (scaleX) en cascade syncopée — sobre, pas de confettis. */
|
||||||
|
.filet-drapeau {
|
||||||
|
display: flex;
|
||||||
|
width: 132px;
|
||||||
|
height: 5px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filet-drapeau .seg {
|
||||||
|
flex: 1;
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filet-drapeau .seg-vert {
|
||||||
|
background: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filet-drapeau .seg-corail {
|
||||||
|
background: var(--oki-corail);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filet-drapeau .seg-bleu {
|
||||||
|
background: var(--oki-bleu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seed rappelée sous le titre du bilan (page /defi/[seed]) */
|
||||||
|
.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.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total .unite {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carte SVG du récap : hauteur plafonnée à 44dvh via la largeur
|
||||||
|
(viewBox 600×620 → hauteur ≈ largeur × 1,033). */
|
||||||
|
.bilan-carte {
|
||||||
|
width: min(100%, 42.5dvh, 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.5rem 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan-actions .btn {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bilan desktop : deux colonnes (carte | récap) pour tenir sans scroll. */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.bilan {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-content: start;
|
||||||
|
column-gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan h1,
|
||||||
|
.bilan .seed-discret,
|
||||||
|
.bilan .filet-drapeau,
|
||||||
|
.bilan .total,
|
||||||
|
.bilan .bilan-actions,
|
||||||
|
.bilan .legende {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan-carte {
|
||||||
|
width: min(100%, 42.5dvh);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hauteur limitée : resserre le bilan pour rester sans scroll dès 800px. */
|
||||||
|
@media (max-height: 800px) {
|
||||||
|
.bilan {
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bilan h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total .valeur {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Motion (playbook §1.7) : transform/opacity uniquement, tout est sous
|
||||||
|
gate html.js + no-preference — contenu statique complet sans JS ni motion. — */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
/* Moment révélation : la fiche résultat monte pendant que la carte prend
|
||||||
|
l'emphase (léger zoom de reprise, --dur-mesure --ease-ka). */
|
||||||
|
html.js .resultat-scroll {
|
||||||
|
animation: jwe-montee var(--dur-mesure) var(--ease-ka);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.js .split.resultat .panneau-carte {
|
||||||
|
animation: jwe-emphase var(--dur-mesure) var(--ease-ka);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pastilles de statut (manche, seed, minuteur) : apparition en pop. */
|
||||||
|
html.js .badge-manche,
|
||||||
|
html.js .badge-seed,
|
||||||
|
html.js .badge-timer {
|
||||||
|
animation: oki-pop var(--dur-mesure) var(--ease-ka) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filet drapeau du bilan : segments qui se dessinent 0 / 120 / 300 ms. */
|
||||||
|
html.js .filet-drapeau .seg {
|
||||||
|
animation: jwe-filet var(--dur-mesure) var(--ease-ka) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.js .filet-drapeau .seg-corail {
|
||||||
|
animation-delay: 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.js .filet-drapeau .seg-bleu {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jwe-montee {
|
||||||
|
from {
|
||||||
|
transform: translateY(18px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jwe-emphase {
|
||||||
|
from {
|
||||||
|
transform: scale(0.965);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jwe-filet {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,61 @@
|
|||||||
'Segoe UI',
|
'Segoe UI',
|
||||||
Roboto,
|
Roboto,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
|
||||||
|
/* Cadences gwoka (playbook §1.7) — lues aussi depuis le JS via
|
||||||
|
getComputedStyle ($lib/motion/tokens.ts : dureeMs). */
|
||||||
|
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1); /* temps fort : attaque franche, longue tenue */
|
||||||
|
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1); /* contretemps : symétrique, urgent */
|
||||||
|
--dur-tanbou: 120ms; /* double-croche — micro-interactions, hovers */
|
||||||
|
--dur-mesure: 480ms; /* une mesure — entrées, transitions d'état */
|
||||||
|
--dur-phrase: 960ms; /* deux mesures — moments solennels (ligne de distance) */
|
||||||
|
--dur-compte: 800ms; /* count-up du score — entre mesure et phrase */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lecture d'écran : valeur finale non animée qui double un compteur animé
|
||||||
|
(le span animé porte aria-hidden="true"). */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pop de marque (pastilles, marqueurs explorer) : scale 0.6 → 1.
|
||||||
|
Toujours appliqué sous gate html.js + no-preference chez l'appelant. */
|
||||||
|
@keyframes oki-pop {
|
||||||
|
from {
|
||||||
|
transform: scale(0.6);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* États initiaux des reveals : uniquement si JS actif (classe `js` posée par
|
||||||
|
app.html) ET motion autorisée — sans JS ou en reduced-motion, tout le
|
||||||
|
contenu est visible (playbook §6). --d = délai syncopé posé dans le markup. */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
html.js [data-reveal] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(28px);
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-mesure) var(--ease-ka) var(--d, 0ms),
|
||||||
|
transform var(--dur-mesure) var(--ease-ka) var(--d, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.js [data-reveal].in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -50,6 +105,9 @@ button {
|
|||||||
|
|
||||||
/* — Boutons — */
|
/* — Boutons — */
|
||||||
.btn {
|
.btn {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate; /* le balayage ::after reste sous le texte */
|
||||||
|
overflow: hidden;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -61,7 +119,11 @@ button {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 0.15s ease;
|
transition:
|
||||||
|
background-color var(--dur-tanbou) var(--ease-ka),
|
||||||
|
color var(--dur-tanbou) var(--ease-ka),
|
||||||
|
border-color var(--dur-tanbou) var(--ease-ka),
|
||||||
|
transform var(--dur-tanbou) var(--ease-ka);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:focus-visible,
|
.btn:focus-visible,
|
||||||
@@ -78,8 +140,25 @@ a:focus-visible,
|
|||||||
box-shadow: var(--oki-ombre);
|
box-shadow: var(--oki-ombre);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primaire:hover {
|
/* Balayage corail foncé qui glisse depuis la gauche (hover craft, pattern
|
||||||
|
.btn OKI adapté au DS corail/vert — transform only) */
|
||||||
|
.btn-primaire::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
background: var(--oki-corail-fonce);
|
background: var(--oki-corail-fonce);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: transform var(--dur-tanbou) var(--ease-syncope);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primaire:not(:disabled):hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primaire:not(:disabled):hover::after {
|
||||||
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primaire:disabled {
|
.btn-primaire:disabled {
|
||||||
|
|||||||
+28
-2
@@ -44,6 +44,27 @@ export interface Lieu {
|
|||||||
aire_commune_km2?: number;
|
aire_commune_km2?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lieu du mode street (« An lari a ») — serveur uniquement.
|
||||||
|
* La photo statique est remplacée par une image Mapillary (CC BY-SA 4.0).
|
||||||
|
* `wikidata_id` pointe vers la COMMUNE (phase éducative : fiche de la commune).
|
||||||
|
*/
|
||||||
|
export interface StreetLieu {
|
||||||
|
id: string;
|
||||||
|
nom: string;
|
||||||
|
nom_creole: string;
|
||||||
|
region: Region;
|
||||||
|
commune: string;
|
||||||
|
coordonnees: Coordonnees;
|
||||||
|
difficulte: Difficulte;
|
||||||
|
mapillary_id: string;
|
||||||
|
credit: { username: string; captured_at: string };
|
||||||
|
is_pano: boolean;
|
||||||
|
wikidata_id: string | null;
|
||||||
|
indices: Indice[];
|
||||||
|
aire_commune_km2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Lieu SANITISÉ envoyé par GET /api/round (aucune coordonnée, commune, région, wikidata_id). */
|
/** Lieu SANITISÉ envoyé par GET /api/round (aucune coordonnée, commune, région, wikidata_id). */
|
||||||
export interface RoundPlace {
|
export interface RoundPlace {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,7 +72,12 @@ export interface RoundPlace {
|
|||||||
nom_creole: string;
|
nom_creole: string;
|
||||||
categorie: Categorie;
|
categorie: Categorie;
|
||||||
difficulte: Difficulte;
|
difficulte: Difficulte;
|
||||||
photo: Photo;
|
/** Modes photo (monument/lieu/defi). Absent en mode street. */
|
||||||
|
photo?: Photo;
|
||||||
|
/** Mode street : identifiant d'image Mapillary à charger dans la visionneuse. */
|
||||||
|
mapillaryId?: string;
|
||||||
|
/** Crédit affiché au résultat en mode street (« © auteur / Mapillary (CC BY-SA 4.0) »). */
|
||||||
|
credit?: string;
|
||||||
indices: Indice[];
|
indices: Indice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +121,4 @@ export interface WikiSummary {
|
|||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameMode = 'monument' | 'lieu' | 'defi';
|
export type GameMode = 'monument' | 'lieu' | 'defi' | 'street';
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app {
|
.app {
|
||||||
min-height: 100dvh;
|
height: 100dvh; /* layout « app » verrouillé : chaque écran gère son overflow interne */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-56
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import CarteRegions from '$lib/components/CarteRegions.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import { t } from '$lib/i18n/store.svelte';
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
import { REGIONS } from '$lib/map/regions';
|
import { reveal } from '$lib/motion/reveal';
|
||||||
|
import { syncopatedDelay } from '$lib/motion/tokens';
|
||||||
import type { GameMode, Region } from '$lib/types';
|
import type { GameMode, Region } from '$lib/types';
|
||||||
|
|
||||||
type ChoixRegion = Region | 'TOUTES';
|
type ChoixRegion = Region | 'TOUTES';
|
||||||
@@ -13,7 +16,8 @@
|
|||||||
const modes: { id: GameMode; emoji: string }[] = [
|
const modes: { id: GameMode; emoji: string }[] = [
|
||||||
{ id: 'monument', emoji: '🏛️' },
|
{ id: 'monument', emoji: '🏛️' },
|
||||||
{ id: 'lieu', emoji: '📍' },
|
{ id: 'lieu', emoji: '📍' },
|
||||||
{ id: 'defi', emoji: '⛰️' }
|
{ id: 'defi', emoji: '⛰️' },
|
||||||
|
{ id: 'street', emoji: '🛣️' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function jouer(): void {
|
function jouer(): void {
|
||||||
@@ -22,55 +26,58 @@
|
|||||||
if (timer) params.set('timer', '1');
|
if (timer) params.set('timer', '1');
|
||||||
goto(`/jeu/${mode}${params.size ? `?${params}` : ''}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo title={t().app.sousTitre} description={t().app.sousTitre} path="/" />
|
||||||
<title>{t().app.titre} — {t().app.sousTitre}</title>
|
|
||||||
<meta name="description" content={t().app.sousTitre} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="accueil">
|
<div class="accueil">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>{t().app.titre}</h1>
|
<h1>
|
||||||
<p class="accroche">{t().micro.kiteSaYe}</p>
|
<span class="masque"><span class="ligne">{t().app.titre}</span></span>
|
||||||
|
</h1>
|
||||||
|
<p class="accroche">
|
||||||
|
<span class="masque"><span class="ligne ligne-retard">{t().micro.kiteSaYe}</span></span>
|
||||||
|
</p>
|
||||||
<p class="sous-titre">{t().app.sousTitre}</p>
|
<p class="sous-titre">{t().app.sousTitre}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="titre-regions">
|
<section class="sec-regions" aria-labelledby="titre-regions">
|
||||||
<h2 id="titre-regions">{t().accueil.choixRegion}</h2>
|
<h2 id="titre-regions">{t().accueil.choixRegion}</h2>
|
||||||
<div class="grille regions">
|
<div class="carte-accueil">
|
||||||
{#each REGIONS as r (r)}
|
<CarteRegions bind:selection={region} cascadeBase={5} />
|
||||||
<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}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="carte-region toutes"
|
class="btn btn-secondaire toutes"
|
||||||
class:choisi={region === 'TOUTES'}
|
class:choisi={region === 'TOUTES'}
|
||||||
aria-pressed={region === 'TOUTES'}
|
aria-pressed={region === 'TOUTES'}
|
||||||
onclick={() => (region = 'TOUTES')}
|
onclick={() => (region = 'TOUTES')}
|
||||||
>
|
>
|
||||||
<span class="nom">{t().regions.TOUTES}</span>
|
{t().regions.TOUTES}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="titre-modes">
|
<section class="sec-modes" aria-labelledby="titre-modes">
|
||||||
<h2 id="titre-modes">{t().accueil.choixMode}</h2>
|
<h2 id="titre-modes">{t().accueil.choixMode}</h2>
|
||||||
<div class="grille modes">
|
<div class="grille modes">
|
||||||
{#each modes as m (m.id)}
|
{#each modes as m, i (m.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="carte-mode"
|
class="carte-mode"
|
||||||
class:choisi={mode === m.id}
|
class:choisi={mode === m.id}
|
||||||
aria-pressed={mode === m.id}
|
aria-pressed={mode === m.id}
|
||||||
|
data-reveal
|
||||||
|
style:--d="{syncopatedDelay(i)}ms"
|
||||||
|
use:reveal
|
||||||
onclick={() => (mode = m.id)}
|
onclick={() => (mode = m.id)}
|
||||||
>
|
>
|
||||||
<span class="emoji" aria-hidden="true">{m.emoji}</span>
|
<span class="emoji" aria-hidden="true">{m.emoji}</span>
|
||||||
@@ -78,12 +85,23 @@
|
|||||||
<span class="desc">{t().modes[`${m.id}Desc`]}</span>
|
<span class="desc">{t().modes[`${m.id}Desc`]}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<a href="/explorer" class="carte-mode explorer">
|
<a
|
||||||
|
href="/explorer"
|
||||||
|
class="carte-mode explorer"
|
||||||
|
data-reveal
|
||||||
|
style:--d="{syncopatedDelay(4)}ms"
|
||||||
|
use:reveal
|
||||||
|
>
|
||||||
<span class="emoji" aria-hidden="true">🧭</span>
|
<span class="emoji" aria-hidden="true">🧭</span>
|
||||||
<span class="nom">{t().modes.explorer}</span>
|
<span class="nom">{t().modes.explorer}</span>
|
||||||
<span class="desc">{t().modes.explorerDesc}</span>
|
<span class="desc">{t().modes.explorerDesc}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{#if mode === 'defi'}
|
||||||
|
<button type="button" class="btn btn-secondaire lien-defi" onclick={creerLienDefi}>
|
||||||
|
{t().accueil.creerLienDefi}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="options" aria-labelledby="titre-options">
|
<section class="options" aria-labelledby="titre-options">
|
||||||
@@ -103,19 +121,57 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.accueil {
|
.accueil {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto; /* scroll interne : l'en-tête reste visible */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.25rem 1rem 7rem; /* espace pour le bouton fixe en zone pouce */
|
padding: 1.25rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.6rem;
|
gap: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reveal masqué du titre : la ligne glisse dans un masque overflow hidden.
|
||||||
|
Actif uniquement sous html.js + no-preference : sans JS ou en
|
||||||
|
reduced-motion, le titre est affiché statiquement. */
|
||||||
|
.masque {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ligne {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes montee-masque {
|
||||||
|
from {
|
||||||
|
transform: translateY(110%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:global(html.js) .masque {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.js) .ligne {
|
||||||
|
animation: montee-masque var(--dur-mesure) var(--ease-ka) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contretemps gwoka : l'accroche suit le titre à 120 ms */
|
||||||
|
:global(html.js) .ligne-retard {
|
||||||
|
animation-delay: 120ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
@@ -148,15 +204,35 @@
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.regions {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.modes {
|
.modes {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
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 {
|
.carte-mode {
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: var(--oki-radius);
|
border-radius: var(--oki-radius);
|
||||||
@@ -166,24 +242,14 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--oki-encre);
|
color: var(--oki-encre);
|
||||||
text-decoration: none;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 1rem 0.8rem;
|
padding: 1rem 0.8rem;
|
||||||
|
transition:
|
||||||
|
transform var(--dur-tanbou) var(--ease-ka),
|
||||||
|
border-color var(--dur-tanbou) var(--ease-ka);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carte-mode .emoji {
|
.carte-mode .emoji {
|
||||||
@@ -200,12 +266,12 @@
|
|||||||
color: var(--oki-encre-doux);
|
color: var(--oki-encre-doux);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carte-region:hover,
|
/* Hover craft : lift + liseré (transform/couleur uniquement) */
|
||||||
.carte-mode:hover {
|
.carte-mode:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
border-color: var(--oki-vert-clair);
|
border-color: var(--oki-vert-clair);
|
||||||
}
|
}
|
||||||
|
|
||||||
.carte-region.choisi,
|
|
||||||
.carte-mode.choisi {
|
.carte-mode.choisi {
|
||||||
border-color: var(--oki-vert);
|
border-color: var(--oki-vert);
|
||||||
background: var(--oki-vert-doux);
|
background: var(--oki-vert-doux);
|
||||||
@@ -231,21 +297,16 @@
|
|||||||
accent-color: var(--oki-vert);
|
accent-color: var(--oki-vert);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bouton « Jouer » : fixe en zone du pouce sur mobile */
|
/* Bouton « Jouer » : barre d'action normale en flux (plus de fixe à dégradé
|
||||||
|
qui rendait le contenu sous-jacent visible mais non cliquable). */
|
||||||
.zone-pouce {
|
.zone-pouce {
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
padding: 0.8rem 1rem calc(0.8rem + env(safe-area-inset-bottom));
|
|
||||||
background: linear-gradient(transparent, var(--oki-sable) 35%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.jouer {
|
.jouer {
|
||||||
width: min(420px, 100%);
|
width: min(420px, 100%);
|
||||||
|
min-height: 48px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -255,4 +316,82 @@
|
|||||||
font-size: 3.6rem;
|
font-size: 3.6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop : deux colonnes — identité + modes à gauche, carte des régions
|
||||||
|
à droite. Tout tient dans le viewport sans scroll dès 900px de haut. */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.accueil {
|
||||||
|
max-width: 1120px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
|
||||||
|
column-gap: 2.5rem;
|
||||||
|
row-gap: 1.1rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
grid-column: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-regions {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / span 5;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-modes,
|
||||||
|
.options,
|
||||||
|
.zone-pouce {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-pouce {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hauteur de la carte SVG plafonnée (viewBox 600×620 → hauteur ≈ largeur × 1,033) */
|
||||||
|
.carte-accueil {
|
||||||
|
max-width: min(560px, calc(clamp(240px, 42dvh, 560px) * 0.94));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewports bas : on resserre titres, espacements et cartes de mode pour
|
||||||
|
que tout reste visible sans scroll (la colonne gauche est la plus haute). */
|
||||||
|
@media (min-width: 1024px) and (max-height: 999px) {
|
||||||
|
.accueil {
|
||||||
|
row-gap: 0.8rem;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accroche {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode {
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode .emoji {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode .desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jouer {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getLieuPhotoById, 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 = getLieuPhotoById(id);
|
||||||
|
if (!lieu) throw new Error(`Tirage incohérent : id inconnu (${id})`);
|
||||||
|
return sanitizeRound(lieu);
|
||||||
|
});
|
||||||
|
return json({ rounds }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { getLieuById, PHOTO_LARGEURS } from '$lib/server/lieux';
|
import { getLieuPhotoById, PHOTO_LARGEURS } from '$lib/server/lieux';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
const USER_AGENT = 'JWE-OKI/2.0 (https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/JWE; contact OKI)';
|
const USER_AGENT = 'JWE-OKI/2.0 (https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/JWE; contact OKI)';
|
||||||
@@ -22,7 +22,7 @@ const enVol = new Map<string, Promise<Buffer | null>>();
|
|||||||
* - conversion WebP + redimensionnement à la largeur demandée.
|
* - conversion WebP + redimensionnement à la largeur demandée.
|
||||||
*/
|
*/
|
||||||
async function chargerPhoto(id: string, w: Largeur): Promise<Buffer | null> {
|
async function chargerPhoto(id: string, w: Largeur): Promise<Buffer | null> {
|
||||||
const lieu = getLieuById(id);
|
const lieu = getLieuPhotoById(id);
|
||||||
if (!lieu) return null;
|
if (!lieu) return null;
|
||||||
const res = await fetch(lieu.photo.url, { headers: { 'User-Agent': USER_AGENT } });
|
const res = await fetch(lieu.photo.url, { headers: { 'User-Agent': USER_AGENT } });
|
||||||
if (!res.ok) throw new Error(`commons ${res.status}`);
|
if (!res.ok) throw new Error(`commons ${res.status}`);
|
||||||
@@ -59,7 +59,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 404 immédiat si l'id est inconnu (avant tout fetch amont).
|
// 404 immédiat si l'id est inconnu (avant tout fetch amont).
|
||||||
if (!getLieuById(id)) {
|
if (!getLieuPhotoById(id)) {
|
||||||
return new Response('Lieu inconnu', { status: 404 });
|
return new Response('Lieu inconnu', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getRandomLieu, sanitizeRound } from '$lib/server/lieux';
|
import { getRandomLieu, getRandomStreet, sanitizeRound, sanitizeRoundStreet } from '$lib/server/lieux';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
/** GET /api/round?region=&categorie=&exclude=id1,id2 → lieu aléatoire SANITISÉ. */
|
/**
|
||||||
|
* GET /api/round?region=&categorie=&exclude=id1,id2 → lieu aléatoire SANITISÉ.
|
||||||
|
* Avec `mode=street` : pioche dans le corpus Mapillary (catégorie ignorée,
|
||||||
|
* réponse avec `mapillaryId` au lieu de `photo`).
|
||||||
|
*/
|
||||||
export const GET: RequestHandler = ({ url }) => {
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
if (url.searchParams.get('mode') === 'street') {
|
||||||
|
const lieu = getRandomStreet({
|
||||||
|
region: url.searchParams.get('region'),
|
||||||
|
exclude: url.searchParams.get('exclude')
|
||||||
|
});
|
||||||
|
if (!lieu) {
|
||||||
|
return json({ erreur: 'Aucun lieu street disponible pour ces critères' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return json(sanitizeRoundStreet(lieu), {
|
||||||
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
|
});
|
||||||
|
}
|
||||||
const lieu = getRandomLieu({
|
const lieu = getRandomLieu({
|
||||||
region: url.searchParams.get('region'),
|
region: url.searchParams.get('region'),
|
||||||
categorie: url.searchParams.get('categorie'),
|
categorie: url.searchParams.get('categorie'),
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import '$lib/styles/jeu.css';
|
||||||
|
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 Seo from '$lib/components/Seo.svelte';
|
||||||
|
import TimerRing from '$lib/components/TimerRing.svelte';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { compte } from '$lib/motion/countup';
|
||||||
|
import { reveal } from '$lib/motion/reveal';
|
||||||
|
import { syncopatedDelay } from '$lib/motion/tokens';
|
||||||
|
import { fonduMesure, monteeMesure } from '$lib/motion/transitions';
|
||||||
|
import { partager } from '$lib/partage';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (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>
|
||||||
|
|
||||||
|
<Seo
|
||||||
|
title="{t().modes.defi} — seed {seed}"
|
||||||
|
description={t().modes.defiDesc}
|
||||||
|
path="/defi/{seed}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="jeu">
|
||||||
|
{#if bilan}
|
||||||
|
<div class="bilan" in:monteeMesure>
|
||||||
|
<h1>{t().defi.bilan}</h1>
|
||||||
|
<p class="seed-discret">{t().defi.titreSeed} <strong>{seed}</strong></p>
|
||||||
|
<div class="filet-drapeau" aria-hidden="true">
|
||||||
|
<span class="seg seg-vert"></span><span class="seg seg-corail"></span><span class="seg seg-bleu"></span>
|
||||||
|
</div>
|
||||||
|
<p class="total">
|
||||||
|
<span class="valeur" aria-hidden="true" use:compte={defi.total}>{defi.total}</span>
|
||||||
|
<span class="sr-only">{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 data-reveal style:--d="{syncopatedDelay(i)}ms" use:reveal>
|
||||||
|
<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" in:fonduMesure>
|
||||||
|
<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} in:monteeMesure>
|
||||||
|
<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 if place.photo}
|
||||||
|
<PhotoPanel photo={place.photo} />
|
||||||
|
{/if}
|
||||||
|
{#if !result && 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}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panneau-carte">
|
||||||
|
<GameMap
|
||||||
|
bind:guess
|
||||||
|
real={result ? result.coordonnees : null}
|
||||||
|
distanceKm={result ? result.distanceKm : null}
|
||||||
|
interactive={!result}
|
||||||
|
onvalidate={() => void soumettre(false)}
|
||||||
|
/>
|
||||||
|
<div class="statut">
|
||||||
|
{#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}
|
||||||
|
<span class="badge-timer">
|
||||||
|
<TimerRing bind:this={timerRef} actif={!result} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !result && !guess}
|
||||||
|
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
|
||||||
|
{/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}
|
||||||
|
{:else}
|
||||||
|
<div class="etat" in:fonduMesure><p>{t().jeu.charger}</p></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -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 { onMount } from 'svelte';
|
||||||
import GameMap from '$lib/components/GameMap.svelte';
|
import GameMap from '$lib/components/GameMap.svelte';
|
||||||
import WikiFiche from '$lib/components/WikiFiche.svelte';
|
import WikiFiche from '$lib/components/WikiFiche.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import { t } from '$lib/i18n/store.svelte';
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
import { REGIONS } from '$lib/map/regions';
|
import { REGIONS } from '$lib/map/regions';
|
||||||
import type { ExplorerLieu, Region } from '$lib/types';
|
import type { ExplorerLieu, Region } from '$lib/types';
|
||||||
@@ -44,9 +45,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo title={t().explorer.titre} description={t().modes.explorerDesc} path="/explorer" />
|
||||||
<title>{t().app.titre} — {t().explorer.titre}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="explorer">
|
<div class="explorer">
|
||||||
<div class="filtres" role="group" aria-label={t().explorer.filtrer}>
|
<div class="filtres" role="group" aria-label={t().explorer.filtrer}>
|
||||||
@@ -121,6 +120,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden; /* la partie repliée du sheet ne doit pas agrandir la page */
|
||||||
}
|
}
|
||||||
|
|
||||||
.filtres {
|
.filtres {
|
||||||
@@ -152,7 +152,8 @@
|
|||||||
.carte-explorer {
|
.carte-explorer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.6rem;
|
padding: 0.6rem 0.6rem 3.6rem; /* réserve la bande de la poignée du sheet :
|
||||||
|
la fiche repliée ne recouvre jamais la carte, les sauts ni l'attribution */
|
||||||
}
|
}
|
||||||
|
|
||||||
.erreur {
|
.erreur {
|
||||||
@@ -160,7 +161,8 @@
|
|||||||
color: var(--oki-encre-doux);
|
color: var(--oki-encre-doux);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bottom sheet mobile */
|
/* Bottom sheet mobile : overlay dont les événements sont scopés à lui-même.
|
||||||
|
Glisse --dur-mesure --ease-ka (cadence gwoka), coupée en reduced-motion. */
|
||||||
.fiche {
|
.fiche {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -173,7 +175,7 @@
|
|||||||
box-shadow: 0 -4px 18px rgba(11, 61, 46, 0.2);
|
box-shadow: 0 -4px 18px rgba(11, 61, 46, 0.2);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
transform: translateY(calc(100% - 3.2rem));
|
transform: translateY(calc(100% - 3.2rem));
|
||||||
transition: transform 0.25s ease;
|
transition: transform var(--dur-mesure) var(--ease-ka);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fiche.ouvert {
|
.fiche.ouvert {
|
||||||
@@ -237,21 +239,32 @@
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Panneau latéral desktop */
|
/* Desktop : panneau latéral droit EN FLUX (grille) — plus aucune bande
|
||||||
|
verticale par-dessus la carte, toute la carte reste cliquable. */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.fiche {
|
.explorer {
|
||||||
left: auto;
|
display: grid;
|
||||||
top: 0;
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
right: 0;
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
bottom: 0;
|
|
||||||
width: 380px;
|
|
||||||
max-height: none;
|
|
||||||
border-radius: var(--oki-radius) 0 0 var(--oki-radius);
|
|
||||||
transform: translateX(calc(100% - 3.2rem));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fiche.ouvert {
|
.filtres {
|
||||||
transform: translateX(0);
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-explorer {
|
||||||
|
padding: 0.8rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: 1px solid var(--oki-sable-fonce);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import '$lib/styles/jeu.css';
|
||||||
import GameMap from '$lib/components/GameMap.svelte';
|
import GameMap from '$lib/components/GameMap.svelte';
|
||||||
|
import CarteRegions, { type MarqueurCarte } from '$lib/components/CarteRegions.svelte';
|
||||||
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
|
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
|
||||||
|
import StreetPanel from '$lib/components/StreetPanel.svelte';
|
||||||
import ResultPanel from '$lib/components/ResultPanel.svelte';
|
import ResultPanel from '$lib/components/ResultPanel.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import TimerRing from '$lib/components/TimerRing.svelte';
|
import TimerRing from '$lib/components/TimerRing.svelte';
|
||||||
import { t } from '$lib/i18n/store.svelte';
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { compte } from '$lib/motion/countup';
|
||||||
|
import { reveal } from '$lib/motion/reveal';
|
||||||
|
import { syncopatedDelay } from '$lib/motion/tokens';
|
||||||
|
import { fonduMesure, monteeMesure } from '$lib/motion/transitions';
|
||||||
|
import { partager } from '$lib/partage';
|
||||||
import { defi } from '$lib/stores/defi.svelte';
|
import { defi } from '$lib/stores/defi.svelte';
|
||||||
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
|
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
|
||||||
|
|
||||||
@@ -30,10 +39,23 @@
|
|||||||
const estDefi = $derived(mode === 'defi');
|
const estDefi = $derived(mode === 'defi');
|
||||||
const mancheCourante = $derived(defi.manches.length + 1);
|
const mancheCourante = $derived(defi.manches.length + 1);
|
||||||
const defiTermine = $derived(estDefi && defi.termine);
|
const defiTermine = $derived(estDefi && defi.termine);
|
||||||
const paires = $derived(
|
// Marqueurs du récap SVG : position réelle (vert) + guess du joueur (corail),
|
||||||
defi.manches
|
// numérotés par manche ; une manche passée (guess null) n'a que le point réel.
|
||||||
.filter((m) => m.guess !== null)
|
const marqueursRecap = $derived(
|
||||||
.map((m) => ({ from: m.guess!, to: m.result.coordonnees, label: m.result.nom }))
|
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> {
|
async function chargerRound(): Promise<void> {
|
||||||
@@ -48,6 +70,7 @@
|
|||||||
if (regionParam) params.set('region', regionParam);
|
if (regionParam) params.set('region', regionParam);
|
||||||
if (mode === 'monument') params.set('categorie', 'MONUMENT');
|
if (mode === 'monument') params.set('categorie', 'MONUMENT');
|
||||||
if (mode === 'lieu') params.set('categorie', 'LIEU');
|
if (mode === 'lieu') params.set('categorie', 'LIEU');
|
||||||
|
if (mode === 'street') params.set('mode', 'street');
|
||||||
if (joues.length > 0) params.set('exclude', joues.join(','));
|
if (joues.length > 0) params.set('exclude', joues.join(','));
|
||||||
const res = await fetch(`/api/round?${params}`);
|
const res = await fetch(`/api/round?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
@@ -112,65 +135,6 @@
|
|||||||
void chargerRound();
|
void chargerRound();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Génère une image canvas téléchargeable : score total + régions jouées. */
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Nouveau défi : on repart de zéro quand on arrive sur la première manche.
|
// Nouveau défi : on repart de zéro quand on arrive sur la première manche.
|
||||||
if (estDefi && defi.termine) defi.reinitialiser();
|
if (estDefi && defi.termine) defi.reinitialiser();
|
||||||
@@ -178,24 +142,30 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<Seo
|
||||||
<title>{t().app.titre} — {t().modes[mode]}</title>
|
title={t().modes[mode]}
|
||||||
</svelte:head>
|
description={t().modes[`${mode}Desc`]}
|
||||||
|
path="/jeu/{mode}"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="jeu">
|
<div class="jeu">
|
||||||
{#if bilan}
|
{#if bilan}
|
||||||
<div class="bilan">
|
<div class="bilan" in:monteeMesure>
|
||||||
<h1>{t().defi.bilan}</h1>
|
<h1>{t().defi.bilan}</h1>
|
||||||
|
<div class="filet-drapeau" aria-hidden="true">
|
||||||
|
<span class="seg seg-vert"></span><span class="seg seg-corail"></span><span class="seg seg-bleu"></span>
|
||||||
|
</div>
|
||||||
<p class="total">
|
<p class="total">
|
||||||
<span class="valeur">{defi.total}</span>
|
<span class="valeur" aria-hidden="true" use:compte={defi.total}>{defi.total}</span>
|
||||||
|
<span class="sr-only">{defi.total}</span>
|
||||||
<span class="unite">{t().defi.scoreTotal}</span>
|
<span class="unite">{t().defi.scoreTotal}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="bilan-carte">
|
<div class="bilan-carte">
|
||||||
<GameMap interactive={false} pairs={paires} />
|
<CarteRegions selectionnable={false} marqueurs={marqueursRecap} />
|
||||||
</div>
|
</div>
|
||||||
<ol class="recap">
|
<ol class="recap">
|
||||||
{#each defi.manches as m, i (m.place.id)}
|
{#each defi.manches as m, i (m.place.id)}
|
||||||
<li>
|
<li data-reveal style:--d="{syncopatedDelay(i)}ms" use:reveal>
|
||||||
<span class="r-nom">{i + 1}. {m.result.nom}</span>
|
<span class="r-nom">{i + 1}. {m.result.nom}</span>
|
||||||
<span class="r-region">{t().regions[m.result.region]}</span>
|
<span class="r-region">{t().regions[m.result.region]}</span>
|
||||||
<span class="r-score">{m.result.score} {t().resultat.points}</span>
|
<span class="r-score">{m.result.score} {t().resultat.points}</span>
|
||||||
@@ -217,45 +187,60 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if chargement}
|
{:else if chargement}
|
||||||
<div class="etat"><p>{t().jeu.charger}</p></div>
|
<div class="etat" in:fonduMesure><p>{t().jeu.charger}</p></div>
|
||||||
{:else if erreur}
|
{:else if erreur}
|
||||||
<div class="etat">
|
<div class="etat" in:fonduMesure>
|
||||||
<p>{t().jeu.erreur}</p>
|
<p>{t().jeu.erreur}</p>
|
||||||
<button type="button" class="btn btn-secondaire" onclick={() => void chargerRound()}>
|
<button type="button" class="btn btn-secondaire" onclick={() => void chargerRound()}>
|
||||||
{t().jeu.reessayer}
|
{t().jeu.reessayer}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if place}
|
{:else if place}
|
||||||
<div class="split" class:resultat={!!result}>
|
<div class="split" class:resultat={!!result} in:monteeMesure>
|
||||||
<section class="panneau-photo">
|
<section class="panneau-photo">
|
||||||
{#if result}
|
{#if result}
|
||||||
<div class="resultat-scroll">
|
<div class="resultat-scroll">
|
||||||
<ResultPanel
|
<ResultPanel
|
||||||
{result}
|
{result}
|
||||||
creditPhoto={place.photo.credit}
|
creditPhoto={place.photo?.credit ?? place.credit}
|
||||||
labelBouton={estDefi && defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
|
labelBouton={estDefi && defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
|
||||||
onsuivant={estDefi && defiTermine ? montrerBilan : suivant}
|
onsuivant={estDefi && defiTermine ? montrerBilan : suivant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if place.mapillaryId}
|
||||||
|
<StreetPanel mapillaryId={place.mapillaryId} credit={place.credit} />
|
||||||
|
{:else if place.photo}
|
||||||
<PhotoPanel photo={place.photo} />
|
<PhotoPanel photo={place.photo} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !result && 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}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panneau-carte">
|
<section class="panneau-carte">
|
||||||
<GameMap
|
<GameMap
|
||||||
bind:guess
|
bind:guess
|
||||||
real={result ? result.coordonnees : null}
|
real={result ? result.coordonnees : null}
|
||||||
|
distanceKm={result ? result.distanceKm : null}
|
||||||
interactive={!result}
|
interactive={!result}
|
||||||
onvalidate={() => void soumettre(false)}
|
onvalidate={() => void soumettre(false)}
|
||||||
/>
|
/>
|
||||||
{#if estDefi && !defiTermine}
|
<div class="statut">
|
||||||
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
|
{#if estDefi && !defiTermine}
|
||||||
{/if}
|
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
|
||||||
{#if timerActif && !result}
|
{/if}
|
||||||
<div class="badge-timer">
|
{#if timerActif && !result}
|
||||||
<TimerRing bind:this={timerRef} actif={!result} />
|
<span class="badge-timer">
|
||||||
</div>
|
<TimerRing bind:this={timerRef} actif={!result} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !result && !guess}
|
||||||
|
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,259 +270,7 @@
|
|||||||
{t().jeu.valider}
|
{t().jeu.valider}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
height: min(46vh, 420px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
|
|||||||
import type { GameMode } from '$lib/types';
|
import type { GameMode } from '$lib/types';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
const MODES: GameMode[] = ['monument', 'lieu', 'defi'];
|
const MODES: GameMode[] = ['monument', 'lieu', 'defi', 'street'];
|
||||||
|
|
||||||
export const load: PageLoad = ({ params }) => {
|
export const load: PageLoad = ({ params }) => {
|
||||||
if (!MODES.includes(params.mode as GameMode)) {
|
if (!MODES.includes(params.mode as GameMode)) {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://jwe.o-k-i.net/sitemap.xml
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://jwe.o-k-i.net/</loc>
|
||||||
|
<lastmod>2026-07-21</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://jwe.o-k-i.net/explorer</loc>
|
||||||
|
<lastmod>2026-07-21</lastmod>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -54,3 +54,34 @@ Chaque fichier est sous licence libre (Creative Commons, CC0 ou domaine public)
|
|||||||
(redimensionnement à 1200 px de large assuré par Wikimedia).
|
(redimensionnement à 1200 px de large assuré par Wikimedia).
|
||||||
- Photos « domaine public » : *La Pelée vue du Carbet.jpg* (Jurassi), *ILES et BAGNE 037.JPG* (davric), *Piton des Neiges1.JPG* (Dunog).
|
- Photos « domaine public » : *La Pelée vue du Carbet.jpg* (Jurassi), *ILES et BAGNE 037.JPG* (davric), *Piton des Neiges1.JPG* (Dunog).
|
||||||
- Photos CC0 (pas d'attribution requise, auteur crédité par courtoisie) : les deux clichés de Thérèse Gaigé (Anse Dufour, Anse Noire).
|
- Photos CC0 (pas d'attribution requise, auteur crédité par courtoisie) : les deux clichés de Thérèse Gaigé (Anse Dufour, Anse Noire).
|
||||||
|
|
||||||
|
## Images Mapillary — mode street « An lari a »
|
||||||
|
|
||||||
|
Les 20 images du corpus `app/src/lib/server/data/street.json` sont servies par la plateforme
|
||||||
|
Mapillary via la visionneuse mapillary-js (licence MIT). Chaque image reste la propriété de son
|
||||||
|
auteur et est publiée sous **CC BY-SA 4.0** (contributeurs Mapillary) — vérifiée via l'API Mapillary
|
||||||
|
(`creator.username`) par `app/scripts/validate-street.mjs`. Attribution affichée dans le jeu :
|
||||||
|
« © <auteur> / Mapillary (CC BY-SA 4.0) » + logo Mapillary intégré à la visionneuse.
|
||||||
|
|
||||||
|
| # | Lieu | Région | Image Mapillary | Auteur | Date de capture | 360° |
|
||||||
|
|---|------|--------|-----------------|--------|-----------------|------|
|
||||||
|
| 1 | Rue Laurence Valmy, Pointe-à-Pitre | GUADELOUPE | [2941939632687980](https://www.mapillary.com/app/?pKey=2941939632687980) | cartophyl | 2015-07-07 | non |
|
||||||
|
| 2 | Rue Achille René Boisneuf, Les Abymes | GUADELOUPE | [306781020957650](https://www.mapillary.com/app/?pKey=306781020957650) | ratzillas | 2015-07-17 | non |
|
||||||
|
| 3 | Place Renovation le Bourg, Le Gosier | GUADELOUPE | [919062132219342](https://www.mapillary.com/app/?pKey=919062132219342) | kaartcam | 2016-01-02 | non |
|
||||||
|
| 4 | Rue Emmanuel Robinet, Sainte-Anne | GUADELOUPE | [956408765176680](https://www.mapillary.com/app/?pKey=956408765176680) | ratzillas | 2019-07-15 | oui |
|
||||||
|
| 5 | Route des Esclaves, Basse-Terre | GUADELOUPE | [212605717097536](https://www.mapillary.com/app/?pKey=212605717097536) | kaartcam | 2016-01-04 | non |
|
||||||
|
| 6 | Rue Victor Sévère, Fort-de-France | MARTINIQUE | [152801196814796](https://www.mapillary.com/app/?pKey=152801196814796) | kaartcam | 2015-12-31 | non |
|
||||||
|
| 7 | Rue des Barrières, Le Lamentin | MARTINIQUE | [1120779875836768](https://www.mapillary.com/app/?pKey=1120779875836768) | Caramel | 2024-04-29 | oui |
|
||||||
|
| 8 | Rue Gabriel Péri, Saint-Pierre | MARTINIQUE | [1299527321706804](https://www.mapillary.com/app/?pKey=1299527321706804) | jeremylavarenne | 2025-08-31 | non |
|
||||||
|
| 9 | Rue Florent Holo, Le François | MARTINIQUE | [4102521129805870](https://www.mapillary.com/app/?pKey=4102521129805870) | kaartcam | 2016-01-01 | non |
|
||||||
|
| 10 | Passage des Fleurs, Sainte-Marie | MARTINIQUE | [131479329019761](https://www.mapillary.com/app/?pKey=131479329019761) | kaartcam | 2015-12-31 | non |
|
||||||
|
| 11 | Rue Francois Arago, Cayenne | GUYANE | [259270559222687](https://www.mapillary.com/app/?pKey=259270559222687) | kaartcam | 2016-08-18 | non |
|
||||||
|
| 12 | Rue de l’Odéon, Kourou | GUYANE | [198127629404270](https://www.mapillary.com/app/?pKey=198127629404270) | hacp | 2022-09-07 | non |
|
||||||
|
| 13 | Avenue Albert Sarraut, Saint-Laurent-du-Maroni | GUYANE | [147142287390967](https://www.mapillary.com/app/?pKey=147142287390967) | pma | 2021-01-18 | non |
|
||||||
|
| 14 | Rue Maurice BOCS, Matoury | GUYANE | [582210316504280](https://www.mapillary.com/app/?pKey=582210316504280) | kaartcam | 2016-08-18 | non |
|
||||||
|
| 15 | Rue de la CANNE À SUCRE, Remire-Montjoly | GUYANE | [247810083801809](https://www.mapillary.com/app/?pKey=247810083801809) | kaartcam | 2016-08-18 | non |
|
||||||
|
| 16 | Rue de Paris, Saint-Denis | REUNION | [2262788723930767](https://www.mapillary.com/app/?pKey=2262788723930767) | AllWays360 | 2022-10-06 | oui |
|
||||||
|
| 17 | Rue du Cimetiere, Saint-Pierre | REUNION | [143202321798356](https://www.mapillary.com/app/?pKey=143202321798356) | sigstpierre | 2022-11-09 | oui |
|
||||||
|
| 18 | Rue Aristide Briand, Le Tampon | REUNION | [1382811445417165](https://www.mapillary.com/app/?pKey=1382811445417165) | vandecasteele | 2019-12-16 | non |
|
||||||
|
| 19 | Rue Elie Eudor, Saint-Paul | REUNION | [928324514646684](https://www.mapillary.com/app/?pKey=928324514646684) | sogefi | 2018-10-14 | non |
|
||||||
|
| 20 | Ccal de la Republique, Saint-André | REUNION | [519625869420978](https://www.mapillary.com/app/?pKey=519625869420978) | vandecasteele | 2019-06-28 | oui |
|
||||||
|
|||||||
@@ -101,3 +101,24 @@ 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.
|
- **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).
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
## 9. Correctif UX résultat (2026-07-17)
|
||||||
|
|
||||||
|
- **Carte résultat façon GeoGuessr** : à la révélation, le marqueur du joueur **reste à sa position** (corail) et la position réelle apparaît (vert), reliées par une ligne pointillée avec **étiquette de distance au milieu** (`distanceKm`) ; la carte cadre les deux points (`fitBounds`, instantané si `prefers-reduced-motion`). Remplace l'ancien « vol » du marqueur vers la position réelle, qui détruisait la comparaison visuelle. Prop `distanceKm` ajoutée à `GameMap` (alimentée par la réponse serveur, jamais calculée côté client).
|
||||||
|
|
||||||
|
## 10. ADR-003 — Mode street « An lari a » via Mapillary (2026-07-18)
|
||||||
|
|
||||||
|
5e mode de jeu : le joueur voit une **photo street-level navigable** (visionneuse Mapillary) au lieu d'une photo statique, et devine la commune. Mécanique carte + guess + score **inchangée**.
|
||||||
|
|
||||||
|
- **Pourquoi Mapillary** : seule source de street-level imagery couvrant correctement les 4 régions (bonne couverture Gwada/Martinique/Réunion, littoral guyanais) ; images **CC BY-SA 4.0**, lib `mapillary-js` **MIT**, API gratuite (token client requis, rate-limité par app). **Exception documentée** à « zéro service tiers » (Mapillary = plateforme Meta), limitée à ce mode ; alternative souveraine future = Panoramax (reco R4 audit passe 2), couverture DOM encore insuffisante à cette date.
|
||||||
|
- **Corpus curé, pas de tirage aléatoire dans l'API** : `app/src/lib/server/data/street.json` (20 lieux, 5/région), produit par `app/scripts/curate-street.mjs` (bbox < 0,01° ou grille de recherches par rayon autour de la **mairie** — le centroïde geo.api tombe souvent hors du centre-ville ; tri qualité/récence, préférence 360°), revérifié par `app/scripts/validate-street.mjs` (image existe, `computed_geometry` < 100 m, crédit auteur, sitelink frwiki). La vérité terrain (`coordonnees`) est **figée** dans le corpus (dérive possible de `computed_geometry` côté Mapillary).
|
||||||
|
- **Phase éducative par commune** : `wikidata_id` = QID de la **commune** (un coin de rue n'a pas vocation à avoir sa page) → `WikiFiche` réutilisé tel quel, aucune variante Cas B nouvelle. `aire_commune_km2` renseigné (équité Guyane, A1).
|
||||||
|
- **Anti-triche** : `/api/round?mode=street` + `sanitizeRoundStreet()` — seuls `id, nom, nom_creole, categorie, difficulte, mapillaryId, credit, indices` partent ; coords/commune/région/wikidata_id jamais avant le guess. **Limitation assumée** : l'`image_key` est publique (la visionneuse appelle Mapillary depuis le navigateur) → un joueur techniquement équipé peut retrouver la position via l'API Mapillary (même propriété que GeoGuessr/Street View ; acceptable en solo sans leaderboard). La visionneuse n'affiche **pas de carte** (mapillary-js n'en a pas par défaut), donc aucun spoiler visuel.
|
||||||
|
- **Visionneuse** : `StreetPanel.svelte`, lazy load façon MapLibre (`import('mapillary-js')` + CSS — chunk séparé ~1 MB, 0 occurrence dans le bundle initial), `component.cover: false`, `moveTo()` avec état d'erreur (image retirée → message + round suivant possible). Token `PUBLIC_MAPILLARY_TOKEN` via `$env/dynamic/public` (pattern `PUBLIC_TILES_URL`) : **token client**, conçu par Mapillary pour être exposé côté navigateur.
|
||||||
|
- **Réutilisation sans modification** : `GameMap`, `ResultPanel`, `WikiFiche`, scoring (`scoreGuess`/`skipResult` acceptent `Lieu | StreetLieu`), timer, indices, bouton « Passer ». `RoundPlace.photo` devient optionnel (`mapillaryId`/`credit` ajoutés) ; lookup unifié `getLieuById` (2 corpus) pour `/api/guess`, `getLieuPhotoById` conservé pour le défi et le proxy photo.
|
||||||
|
- **Hors scope v1** : street dans `/defi/[seed]` et `/explorer`, cache SW des tuiles Mapillary (les requêtes `graph.mapillary.com`/FBCDN passent en direct).
|
||||||
|
|||||||
@@ -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` |
|
| 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é.
|
**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.
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,8 @@
|
|||||||
# JWE v2 — Guide de déploiement auto-hébergé
|
# JWE v2 — Guide de déploiement auto-hébergé
|
||||||
|
|
||||||
Cible : un petit VPS (1 vCPU / 1 Go RAM suffit) sous Debian/Ubuntu, derrière nginx ou Caddy. Aucun service propriétaire, aucune clé API.
|
Cible : un petit VPS (1 vCPU / 1 Go RAM suffit) sous Debian/Ubuntu, derrière nginx ou Caddy. Aucun service propriétaire, aucune clé API payante.
|
||||||
|
|
||||||
|
> **Exception documentée (mode street)** : le 5e mode « An lari a » utilise la visionneuse Mapillary (images CC BY-SA 4.0, lib MIT) et requiert un **token client Mapillary gratuit** — voir §2.1. Sans ce token, les 4 autres modes fonctionnent normalement ; seul le mode street affiche un message d'indisponibilité.
|
||||||
|
|
||||||
## 1. Application SvelteKit
|
## 1. Application SvelteKit
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ WorkingDirectory=/srv/jwe/app
|
|||||||
Environment=PORT=3000
|
Environment=PORT=3000
|
||||||
Environment=ORIGIN=https://jwe.o-k-i.net
|
Environment=ORIGIN=https://jwe.o-k-i.net
|
||||||
Environment=PUBLIC_TILES_URL=https://tiles.o-k-i.net/styles/oki/style.json
|
Environment=PUBLIC_TILES_URL=https://tiles.o-k-i.net/styles/oki/style.json
|
||||||
|
Environment=PUBLIC_MAPILLARY_TOKEN=MLY|xxxxxxxx|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
ExecStart=/usr/bin/node build
|
ExecStart=/usr/bin/node build
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|
||||||
@@ -39,6 +42,16 @@ Restart=on-failure
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 2.1 Token Mapillary (mode street « An lari a »)
|
||||||
|
|
||||||
|
1. Créez un compte sur [mapillary.com](https://www.mapillary.com) puis une application sur le [dashboard développeurs](https://www.mapillary.com/dashboard/developers) : récupérez le **client token** (format `MLY|…|…`).
|
||||||
|
2. Exposez-le via `PUBLIC_MAPILLARY_TOKEN` (service systemd ci-dessus, ou `app/.env` en développement — voir `app/.env.example`). C'est un **token client** : conçu pour être visible côté navigateur, rate-limité par application — ce n'est pas un secret.
|
||||||
|
3. Vérifiez périodiquement le corpus street (images parfois retirées par leurs auteurs) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app && node scripts/validate-street.mjs # lit le token depuis .env
|
||||||
|
```
|
||||||
|
|
||||||
## 2. Reverse proxy (exemple nginx)
|
## 2. Reverse proxy (exemple nginx)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
# JWE — ADR-001 & ADR-002 (format audit OKI)
|
|
||||||
|
|
||||||
Deux décisions d'architecture rédigées selon le format du prompt d'audit post-exécution : critères bloquants, preuves exigées, rapport tabulaire, interdits. Elles **ne modifient pas** le build v1 en cours ; elles cadrent la phase 2 et une optimisation perf.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# ADR-001 — Mode Défi : seed-link en v1, PlaySocketJS en phase 2 conditionnée
|
|
||||||
|
|
||||||
- **Statut** : Accepté (v1) / À étudier (v2, gate explicite)
|
|
||||||
- **Date** : 2026-07-17
|
|
||||||
- **Décideurs** : Architecte OKI
|
|
||||||
- **Références** : PlaySocketJS (MIT, rooms, CRDT, server-authoritative, battle-tested >1,3 M duels sur 1 instance Node) ; Geoguess/Geoguess (modèle de room sans compte, max 5 joueurs) ; prompt v2 §A5 (pas de comptes, pas de leaderboard serveur).
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
Le prompt v2 interdit comptes et leaderboard serveur. L'usage réel visé (OKI, AKILPA, salles de classe, événements) crée pourtant un besoin : **jouer les mêmes 5 lieux que d'autres et comparer les scores**. Deux horizons : maintenant (zéro serveur) et plus tard (temps réel, si le projet prouve sa traction).
|
|
||||||
|
|
||||||
## Décision
|
|
||||||
|
|
||||||
**v1 — Défi asynchrone par lien à seed partagée.**
|
|
||||||
- L'URL de défi contient une seed opaque (ex. `/defi/<seed>`) ; le serveur (ou la fonction SvelteKit) dérive de la seed les mêmes 5 lieux pour tous les joueurs (PRNG seedé côté serveur, jamais côté client).
|
|
||||||
- Aucune coordonnée dans l'URL ni dans le payload initial (anti-triche §6 du prompt v2 maintenu).
|
|
||||||
- Fin de partie : l'image de partage canvas (déjà prévue prompt v2 §4.4) affiche le score ; la comparaison se fait humainement (screenshot dans le groupe WhatsApp/Signal). Zéro serveur d'état, zéro compte, cohérent doctrine.
|
|
||||||
|
|
||||||
**v2 — Duel temps réel via PlaySocketJS, UNIQUEMENT si le gate §"Conditions" est franchi.**
|
|
||||||
- Modèle : rooms sans compte à la Geoguess (nom de room partagé, 2–5 joueurs), état synchronisé par PlaySocketJS en mode **server-authoritative** (le serveur valide les guesses et calcule les scores — jamais le client).
|
|
||||||
- Hébergement : 1 instance Node sur le VPS OKI existant, dans l'enveloppe 40–50 €/mois. Aucune dépendance Google/Firebase (contraire à Geoguess — voir annexe).
|
|
||||||
|
|
||||||
## Options considérées
|
|
||||||
|
|
||||||
| Option | Souveraineté | Coût serveur | Effort | Anti-triche | Verdict |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Seed-link asynchrone (v1) | Totale | Nul | S | Serveur dérive les lieux, scores non comparés en ligne | **Retenu v1** |
|
|
||||||
| PlaySocketJS rooms (v2) | Bonne (MIT, self-host) | 1 instance Node | M | Server-authoritative natif | **Retenu v2 si gate** |
|
|
||||||
| Socket.io maison | Bonne | 1 instance Node | M–L | À réimplémenter | Rejeté (réinvention) |
|
|
||||||
| Supabase/Firebase Realtime | Mauvaise (cloud propriétaire) | Abonnement | M | Faible côté client | Rejeté (doctrine) |
|
|
||||||
|
|
||||||
## Conditions de déclenchement de la v2 (gate — bloquant)
|
|
||||||
|
|
||||||
La v2 n'est étudiée que si TOUTES les conditions suivantes sont remplies et documentées dans le rapport de gate :
|
|
||||||
1. Traction mesurée : ≥ 30 parties/semaine pendant 4 semaines consécutives (mesure locale anonymisée, pas d'analytics tiers).
|
|
||||||
2. Demande explicite : ≥ 5 retours d'utilisateurs ou partenaires (OKI, AKILPA, enseignants) demandant le défi en direct.
|
|
||||||
3. Capacité d'hébergement confirmée sur le VPS OKI sans dépasser l'enveloppe 40–50 €/mois.
|
|
||||||
|
|
||||||
## Vérifications (obligatoires, preuves à l'appui)
|
|
||||||
|
|
||||||
- **V1 (bloquant, v1)** — Déterminisme : la même URL de défi produit les mêmes 5 lieux, dans le même ordre, sur deux machines différentes. Preuve : double exécution filmée ou logs serveur.
|
|
||||||
- **V2 (bloquant, v1)** — Anti-fuite : l'URL et les payloads initiaux ne contiennent ni coordonnées, ni commune, ni `wikidata_id` décodable. Preuve : inspection réseau + revue de l'URL.
|
|
||||||
- **V3 (bloquant, v1)** — L'image de partage n'affiche que score/pseudo libre, jamais les réponses des autres joueurs.
|
|
||||||
- **V4 (bloquant, v2)** — POC PlaySocketJS : room à 3 clients, déconnexion/reconnexion d'un joueur sans perte d'état (CRDT), score validé côté serveur (tentative de triche client rejetée — preuve par requête forgée).
|
|
||||||
- **V5 (v2)** — Licence : `npm view playsocketjs license` = MIT, consigné dans le rapport.
|
|
||||||
- **V6 (v2)** — Charge : 10 rooms simultanées × 5 joueurs sur le VPS sans saturation (preuve : log ressources).
|
|
||||||
|
|
||||||
## Rapport (format obligatoire)
|
|
||||||
|
|
||||||
```
|
|
||||||
| ID | Vérification | Méthode / Preuve | PASS/FAIL | Correctif appliqué |
|
|
||||||
```
|
|
||||||
|
|
||||||
## Interdits
|
|
||||||
|
|
||||||
- Aucun compte utilisateur, aucun leaderboard global, aucun stockage de pseudos au-delà de la room.
|
|
||||||
- Aucune dépendance Google/Firebase/Supabase pour le temps réel.
|
|
||||||
- Aucune copie de code Geoguess/Geoguess (Vue.js) : référence de design uniquement.
|
|
||||||
- La v2 ne doit pas régresser le hors-scope du prompt v2 pour le solo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# ADR-002 — Carte SVG/topojson légère pour les écrans hors-jeu (perf 4G)
|
|
||||||
|
|
||||||
- **Statut** : Accepté
|
|
||||||
- **Date** : 2026-07-17
|
|
||||||
- **Références** : composant carte SVG/topojson d'OpenGuessr Education (inspiration uniquement — licence MIT + Commons Clause = non-commercial, **aucune copie de code**) ; geo.api.gouv.fr (contours de communes GeoJSON, gratuit, sans clé — testé OK) ; prompt v2 §4.1 (lazy-load MapLibre) et §4.5 (perf).
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
MapLibre GL JS pèse ~800 kB de JS avant la première tile. L'accueil, le récapitulatif final et les fiches « Aprann » n'ont pas besoin d'une carte interactive complète. Sur 4G caribéen, charger MapLibre sur ces écrans détruit le budget < 3 s du prompt v2.
|
|
||||||
|
|
||||||
## Décision
|
|
||||||
|
|
||||||
- Les écrans **hors-jeu** (accueil 4 régions, mini-carte du récapitulatif, vignette de fiche lieu) utilisent une **carte SVG statique** générée au build depuis un topojson des communes des 4 régions, projection précalculée, aucune tile, aucun JS carte.
|
|
||||||
- MapLibre reste réservé aux écrans **de jeu** (devinette, résultat, explorer interactif) et reste lazy-loadé.
|
|
||||||
- Pipeline données : contours communes via `geo.api.gouv.fr/communes?codeRegion=01|02|03|04&fields=nom,code,contour&format=json&geometry=contour` → simplification (mapshaper, seuil à calibrer) → topojson versionné dans le repo → composant Svelte SVG from scratch (MIT).
|
|
||||||
|
|
||||||
## Vérifications
|
|
||||||
|
|
||||||
- **V1 (bloquant)** — Budget poids : topojson simplifié des 4 régions ≤ 150 kB gzippé ; preuve : taille du fichier + `gzip -9` consignés. Si dépassement : simplifier davantage ou découper par région avec import dynamique.
|
|
||||||
- **V2 (bloquant)** — LCP accueil < 2,5 s en throttling 4G (Lighthouse mobile, 3 runs, médiane consignée).
|
|
||||||
- **V3** — Rendu sans JS : la carte SVG s'affiche en SSG avec JS désactivé ; chaque région est un `<a>` ou `<button>` réel (accessibilité clavier, lecteur d'écran : nom de région en FR + créole).
|
|
||||||
- **V4** — Attribution données : © contributeurs OpenStreetMap / réutilisation données publiques mentionnée dans le footer (licence ODbL pour les contours dérivés OSM le cas échéant).
|
|
||||||
- **V5** — Zéro code copié d'OpenGuessr Education : revue de provenance, le composant est écrit from scratch (licence MIT du projet).
|
|
||||||
|
|
||||||
## Rapport (format obligatoire)
|
|
||||||
|
|
||||||
```
|
|
||||||
| ID | Vérification | Méthode / Preuve | PASS/FAIL | Correctif appliqué |
|
|
||||||
```
|
|
||||||
|
|
||||||
## Interdits
|
|
||||||
|
|
||||||
- Ne pas charger MapLibre (ni son CSS) sur l'accueil ou le récapitulatif.
|
|
||||||
- Ne pas embarquer de tuiles raster de substitution (le SVG est vectoriel pur).
|
|
||||||
- Ne pas copier le code d'OpenGuessr Education (Commons Clause).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Annexe — Étude Geoguess/Geoguess (référence, 2026-07-17)
|
|
||||||
|
|
||||||
- **Projet** : clone GeoGuessr open source, solo + multijoueur par rooms (nom de room partagé, ≤ 5 joueurs), PWA, cartes custom GeoJSON, licence **MIT**.
|
|
||||||
- **Stack** : Vue.js + Google Maps StreetView + Firebase → **non réutilisable** (doctrine zéro Google ; Vue ≠ Svelte).
|
|
||||||
- **Signal fort** : leur README admet que la démo publique est limitée par le **prix de l'API Google** et renvoie vers l'auto-déploiement avec clé personnelle — justification directe du zéro propriétaire OKI, à citer dans l'ADR d'architecture initial.
|
|
||||||
- **À retenir** : (1) le modèle de room sans compte (base UX de l'ADR-001 v2) ; (2) le repo **GeoGuess-Maps**, liste de cartes communautaires — modèle pour les futurs map-packs JWE contribués par la communauté OKI/AKILPA via PR, sans CMS.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# Mission : JWE — Audit post-exécution, vérifications obligatoires & recommandations
|
|
||||||
|
|
||||||
## À exécuter par chaque agent à la fin de sa tâche, avant de la marquer « terminée »
|
|
||||||
|
|
||||||
La refonte JWE a été produite à partir du prompt de mission v2 (Svelte 5 + SvelteKit 2, MapLibre, SQLite, 4 régions : Guadeloupe, Martinique, Guyane, La Réunion). Cet audit est la seconde passe. **Aucune tâche n'est terminée sans son rapport de vérification** (format §6). Un FAIL non résolu bloque la livraison. Si le correctif dépasse le scope de l'agent, l'agent produit une RECOMMANDATION argumentée au lieu de coder.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Faits établis (à lire avant tout — ils corrigent le prompt v2)
|
|
||||||
|
|
||||||
1. **Le QID d'exemple du prompt v2 était faux** : Fort Delgrès = `Q3077840` (vérifié), pas `Q2216838`. Ses coordonnées officielles sont 15°59′19″N, 61°43′23″O (≈ 15.9886, -61.7231), pas (16.0036, -61.7319). Si le seed ou l'exemple a été copié tel quel, corriger.
|
|
||||||
2. **Principe absolu : fetch, pas recall.** Tout QID, toute coordonnée, toute URL photo, toute licence doit être vérifié par une requête réelle, jamais depuis la mémoire du modèle.
|
|
||||||
3. **Les communes de Guyane sont immenses** (Maripasoula ≈ 18 000 km², Saint-Laurent-du-Maroni ≈ 4 800 km²). Un radius de score fixe par difficulté y est injuste.
|
|
||||||
4. **La Réunion est à ~12 000 km des Antilles** : un `maxBounds` global couvrant les 4 régions rend une carte inutilisable (95 % d'océan), et un zoom automatique sur la région du lieu au début du round révèle la réponse.
|
|
||||||
5. **Un lieu sans page Wikipedia a rarement une photo sur Wikimedia Commons** : le piège « 2-3 lieux sans article frwiki avec photo libre » exige des photos propres (collectif OKI) ou Panoramax.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Agent Contenu — vérifications données
|
|
||||||
|
|
||||||
- **C1 — QID valides (bloquant).** Pour CHAQUE lieu avec `wikidata_id` non null :
|
|
||||||
```
|
|
||||||
curl "https://www.wikidata.org/w/api.php?action=wbgetentities&ids=<QID>&props=sitelinks|labels&languages=fr&format=json" -H "User-Agent: JWE-OKI/1.0"
|
|
||||||
```
|
|
||||||
Le sitelink `frwiki` doit exister et correspondre au lieu (comparer le label). Preuve : tableau `lieu → QID → sitelink frwiki → OK/KO` dans le rapport.
|
|
||||||
- **C2 — Les « null » sont de vrais null (bloquant).** Pour chaque lieu à `wikidata_id: null` intentionnel, prouver par une recherche API (`action=query&list=search` sur frwiki) qu'aucun article frwiki n'existe. Sinon le lieu ne teste pas le Cas B.
|
|
||||||
- **C3 — Coordonnées précises.** Écart < 500 m avec la source officielle (Wikidata `P625`, IGN, geo.api.gouv.fr). Corriger le seed Fort Delgrès (voir §0.1).
|
|
||||||
- **C4 — Photos sans fuite.** (a) Noms de fichiers opaques (`uuid.webp`, jamais `fort-delgres.webp` — le nom ne doit pas révéler la réponse dans les devtools). (b) EXIF strippés : preuve `exiftool <fichier>` sans GPS ni appareil. (c) Licence vérifiable : page de description Commons atteignable, crédit et licence exacts dans le JSON.
|
|
||||||
- **C5 — Photos des lieux « null ».** Source documentée : photo propre OKI (crédit collectif) ou Panoramax (instance + ID + licence). Si introuvable : remplacer le lieu, ne jamais garder une photo sans licence claire.
|
|
||||||
|
|
||||||
## 2. Agent Architecte — vérifications décisions
|
|
||||||
|
|
||||||
- **A1 — Score juste en Guyane (bloquant).** Vérifier que la formule utilise un radius effectif : `radius_eff = max(radius_difficulté, rayon_commune)` avec `rayon_commune ≈ sqrt(aire_commune / π)`. L'aire peut être précalculée dans le JSON du lieu (champ `aire_commune_km2`) ou dérivée du contour geo.api.gouv.fr. Test à produire : un guess à 40 km du chef-lieu de Maripasoula ne doit pas être quasi-nul.
|
|
||||||
- **A2 — Pas de fuite de région (bloquant).** Au début d'un round, la caméra ne doit révéler ni la région ni la commune du lieu. Si la solution retenue est la devinette en deux temps (choix de la région via 4 boutons → zoom → placement du marqueur), vérifier que le scoring combine les deux étapes et que c'est documenté dans l'ADR. Si une autre solution a été codée, vérifier l'absence de fuite et l'usage mobile.
|
|
||||||
- **A3 — Chaîne Wikipedia exacte.** `wbgetentities(props=sitelinks)` → titre frwiki → `fr.wikipedia.org/api/rest_v1/page/summary/{titre}`. Cache ≥ 24 h (pas de « temps réel »). User-Agent explicite côté proxy. Fallback de langue documenté : fr → en. **Cas intermédiaire tranché et documenté** : QID existant mais pas d'article frwiki → recommandé : afficher le Cas B variante « pas encore d'article en français — et si tu le traduisais/créais ? ».
|
|
||||||
- **A4 — Décision Strapi vs SvelteKit.** L'ADR doit appliquer le critère du prompt (contributeurs non-devs ?). En l'absence de contributeurs non-devs aujourd'hui, la cohérence doctrine OKI = SvelteKit seul + JSON versionné + script de validation au commit. Si Strapi a été retenu, l'ADR doit justifier par des contributeurs réels identifiés.
|
|
||||||
- **A5 — Hors scope respecté.** Pas de comptes utilisateurs, pas de leaderboard serveur, pas de multijoueur. Scores en `localStorage`. Tout écart = FAIL ou RECOMMANDATION de suppression.
|
|
||||||
|
|
||||||
## 3. Agent Back-end — vérifications (si applicable)
|
|
||||||
|
|
||||||
- **B1 — Endpoint lieu aléatoire sans fuite (bloquant).** Preuve `curl` : la réponse ne contient ni `coordonnees`, ni `commune`, ni indice de région ; URL photo opaque.
|
|
||||||
- **B2 — Score calculé côté serveur (bloquant).** Le client envoie `{lieu_id, lat, lon, région_choisie?, indices_utilisés, temps}` ; le serveur calcule (formule A1) et renvoie score + distance + coordonnées réelles. Le client ne calcule rien.
|
|
||||||
- **B3 — Proxy Wikipedia.** Cache effectif (headers ou store, TTL ≥ 24 h), User-Agent conforme, **aucun texte d'article stocké en base** (contrainte §2.3 du prompt v2).
|
|
||||||
- **B4 — Pas de dépendance propriétaire.** Audit `package.json` : zéro clé API payante, zéro Google/Mapbox, télémétrie désactivée.
|
|
||||||
|
|
||||||
## 4. Agent Front-end — vérifications
|
|
||||||
|
|
||||||
- **F1 — Carte.** Bounds effectifs, aucune fuite de région (voir A2), MapLibre lazy-loadé (vérifier le chunk réseau : pas de JS MapLibre sur l'accueil), boutons zoom visibles, navigation clavier.
|
|
||||||
- **F2 — Cas A / Cas B (bloquant).** Les deux écrans de résultat sont **visuellement distincts** (couleur, icône), le message de lacune est présent en FR **et** en créole, et le CTA « Kontribyé sou Wikipédia » ouvre bien l'éditeur/recherche pré-remplie ou le guide OKI. Captures à l'appui.
|
|
||||||
- **F3 — Cas intermédiaire géré.** QID présent mais pas d'article frwiki : pas d'écran vide ni d'erreur console ; comportement conforme à la décision A3.
|
|
||||||
- **F4 — Écran résultat enrichi.** Si le contour de commune est intégré (geo.api.gouv.fr, gratuit sans clé : `https://geo.api.gouv.fr/communes?code=<insee>&fields=nom,contour&format=json&geometry=contour`), vérifier l'affichage du polygone et le respect de `prefers-reduced-motion` sur l'animation du marqueur.
|
|
||||||
- **F5 — Doctrine OKI.** Une seule action primaire par écran ; bouton Valider en zone du pouce sur mobile ; timer non anxiogène ; micro-interactions en créole ; style carte non-Google.
|
|
||||||
- **F6 — Performance 4G caribéen.** Premier écran < 3 s en throttling 4G ; photos WebP/AVIF avec `srcset` et lazy loading ; poids d'un round (hors tiles) mesuré et indiqué dans le rapport.
|
|
||||||
|
|
||||||
## 5. Agent QA — passe finale
|
|
||||||
|
|
||||||
- **Q1 — Rejouer les bloquants.** C1, C2, A1, A2, B1, B2, F2 au minimum, avec preuves.
|
|
||||||
- **Q2 — Partie complète** dans chaque mode (Konnèt moniman, Kote mwen ye ?, Défi 5 rounds, Aprann), mobile et desktop, dont au moins un round tombant sur un lieu Cas B et un lieu guyanais.
|
|
||||||
- **Q3 — Checklist OKI du prompt v2 §4 (Agent Intégration & QA)** intégralement passée.
|
|
||||||
- **Q4 — Scope creep.** Signaler toute feature absente du prompt v2 (auth, comptes, partage réseau social, analytics…).
|
|
||||||
- **Q5 — Core Web Vitals** mobile 4G mesurés (LCP, INP, CLS) et consignés.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Format du rapport de vérification (obligatoire, un par agent)
|
|
||||||
|
|
||||||
```
|
|
||||||
| ID | Vérification | Méthode / Preuve (commande, requête, capture) | PASS/FAIL | Correctif appliqué |
|
|
||||||
|----|--------------|-----------------------------------------------|-----------|--------------------|
|
|
||||||
```
|
|
||||||
|
|
||||||
Suivi d'une section **Recommandations** pour tout ce qui dépasse le scope, triée : `bloquant` / `important` / `confort`, avec pour chacune : problème, solution proposée, effort estimé (S/M/L), fichier(s) concerné(s).
|
|
||||||
|
|
||||||
Commits atomiques préfixés : `verify:` (preuves), `fix:` (correctifs), `docs:` (ADR, rapport).
|
|
||||||
|
|
||||||
## 7. Interdits pendant l'audit
|
|
||||||
|
|
||||||
- Aucune nouvelle feature (l'audit corrige et recommande, il n'étend pas).
|
|
||||||
- Aucun refactor hors correctif de FAIL.
|
|
||||||
- Aucun PASS sans preuve exécutable ou capture.
|
|
||||||
- Ne pas « réparer » une donnée (QID, coordonnée, licence) depuis la mémoire : requête réelle ou suppression du lieu.
|
|
||||||
Reference in New Issue
Block a user