feat(app): mode street 'An lari a' - visionneuse mapillary-js lazy, /api/round?mode=street, i18n FR/GCF (ADR-003)

This commit is contained in:
sucupira
2026-07-18 20:15:03 -04:00
parent fab020ba75
commit e4a0bfd045
15 changed files with 546 additions and 27 deletions
+115
View File
@@ -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>
+6
View File
@@ -17,6 +17,8 @@ const fr = {
lieuDesc: 'Une rue, un bord de mer, une forêt… devine où tu es.',
defi: 'Défi 5 rounds',
defiDesc: '5 lieux daffilé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',
explorerDesc: 'Explore la carte librement et lis les fiches Wikipédia. Sans score.'
},
@@ -40,6 +42,10 @@ const fr = {
indicesRestants: 'indices restants',
plusDIndice: 'Plus dindice disponible',
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',
sur: 'sur',
difficulte: {
+6
View File
@@ -19,6 +19,8 @@ const gcf: Dict = {
lieuDesc: 'On lari, on bodlanmè, on fowé… chwé kote ou yé.',
defi: 'Défi 5 rounds',
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',
explorerDesc: 'Flanné asou kat-la é li fich Wikipédia yo. San pwen.'
},
@@ -42,6 +44,10 @@ const gcf: Dict = {
indicesRestants: 'endis ki rété',
plusDIndice: 'Pa ni endis ankò',
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',
sur: 'asou',
difficulte: {
+60 -5
View File
@@ -1,4 +1,5 @@
import rawLieux from './data/lieux.json';
import rawStreet from './data/street.json';
import type {
Categorie,
Difficulte,
@@ -6,10 +7,15 @@ import type {
GuessResult,
Lieu,
Region,
RoundPlace
RoundPlace,
StreetLieu
} from '$lib/types';
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 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;
}
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);
}
@@ -74,6 +86,28 @@ export function getRandomLieu(options: {
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=. */
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).
* 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 / π).
* 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 aire = lieu.aire_commune_km2;
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.
*/
export function scoreGuess(
lieu: Lieu,
lieu: Scoreable,
guess: { lat: number; lon: number },
hintsUsed: 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. */
export function skipResult(lieu: Lieu): GuessResult {
export function skipResult(lieu: Scoreable): GuessResult {
return {
distanceKm: null,
score: 0,
+28 -2
View File
@@ -44,6 +44,27 @@ export interface Lieu {
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). */
export interface RoundPlace {
id: string;
@@ -51,7 +72,12 @@ export interface RoundPlace {
nom_creole: string;
categorie: Categorie;
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[];
}
@@ -95,4 +121,4 @@ export interface WikiSummary {
thumbnail: string | null;
}
export type GameMode = 'monument' | 'lieu' | 'defi';
export type GameMode = 'monument' | 'lieu' | 'defi' | 'street';
+2 -1
View File
@@ -13,7 +13,8 @@
const modes: { id: GameMode; emoji: string }[] = [
{ id: 'monument', emoji: '🏛️' },
{ id: 'lieu', emoji: '📍' },
{ id: 'defi', emoji: '⛰️' }
{ id: 'defi', emoji: '⛰️' },
{ id: 'street', emoji: '🛣️' }
];
function jouer(): void {
+2 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { getLieuById, sanitizeRound } from '$lib/server/lieux';
import { getLieuPhotoById, sanitizeRound } from '$lib/server/lieux';
import { idsPourSeed, seedValide } from '$lib/server/tirage';
import type { RequestHandler } from './$types';
@@ -17,7 +17,7 @@ export const GET: RequestHandler = ({ params }) => {
);
}
const rounds = idsPourSeed(params.seed).map((id) => {
const lieu = getLieuById(id);
const lieu = getLieuPhotoById(id);
if (!lieu) throw new Error(`Tirage incohérent : id inconnu (${id})`);
return sanitizeRound(lieu);
});
+3 -3
View File
@@ -1,5 +1,5 @@
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';
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.
*/
async function chargerPhoto(id: string, w: Largeur): Promise<Buffer | null> {
const lieu = getLieuById(id);
const lieu = getLieuPhotoById(id);
if (!lieu) return null;
const res = await fetch(lieu.photo.url, { headers: { 'User-Agent': USER_AGENT } });
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).
if (!getLieuById(id)) {
if (!getLieuPhotoById(id)) {
return new Response('Lieu inconnu', { status: 404 });
}
+18 -2
View File
@@ -1,9 +1,25 @@
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';
/** 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 }) => {
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({
region: url.searchParams.get('region'),
categorie: url.searchParams.get('categorie'),
+2 -2
View File
@@ -252,12 +252,12 @@
<div class="resultat-scroll">
<ResultPanel
{result}
creditPhoto={place.photo.credit}
creditPhoto={place.photo?.credit}
labelBouton={defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
onsuivant={defiTermine ? montrerBilan : suivant}
/>
</div>
{:else}
{:else if place.photo}
<PhotoPanel photo={place.photo} />
{/if}
</section>
+6 -2
View File
@@ -4,6 +4,7 @@
import GameMap from '$lib/components/GameMap.svelte';
import CarteRegions, { type MarqueurCarte } from '$lib/components/CarteRegions.svelte';
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
import StreetPanel from '$lib/components/StreetPanel.svelte';
import ResultPanel from '$lib/components/ResultPanel.svelte';
import TimerRing from '$lib/components/TimerRing.svelte';
import { t } from '$lib/i18n/store.svelte';
@@ -62,6 +63,7 @@
if (regionParam) params.set('region', regionParam);
if (mode === 'monument') params.set('categorie', 'MONUMENT');
if (mode === 'lieu') params.set('categorie', 'LIEU');
if (mode === 'street') params.set('mode', 'street');
if (joues.length > 0) params.set('exclude', joues.join(','));
const res = await fetch(`/api/round?${params}`);
if (!res.ok) throw new Error(String(res.status));
@@ -246,12 +248,14 @@
<div class="resultat-scroll">
<ResultPanel
{result}
creditPhoto={place.photo.credit}
creditPhoto={place.photo?.credit ?? place.credit}
labelBouton={estDefi && defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
onsuivant={estDefi && defiTermine ? montrerBilan : suivant}
/>
</div>
{:else}
{:else if place.mapillaryId}
<StreetPanel mapillaryId={place.mapillaryId} credit={place.credit} />
{:else if place.photo}
<PhotoPanel photo={place.photo} />
{/if}
</section>
+1 -1
View File
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { GameMode } from '$lib/types';
import type { PageLoad } from './$types';
const MODES: GameMode[] = ['monument', 'lieu', 'defi'];
const MODES: GameMode[] = ['monument', 'lieu', 'defi', 'street'];
export const load: PageLoad = ({ params }) => {
if (!MODES.includes(params.mode as GameMode)) {