app: refonte JWE v2 - SvelteKit 2 + Svelte 5 + MapLibre, 4 modes de jeu, API anti-triche, ecran Cas B 'Pwen blinde', i18n FR/GCF, PWA
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.vite
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ORGANISATION KA INTERNATIONALE (OKI) & contributeurs JWE
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Note : les photographies affichées par l'application restent la propriété de
|
||||||
|
leurs auteurs respectifs et sont distribuées sous leurs licences Wikimedia
|
||||||
|
Commons (voir content/credits.md). Les extraits d'articles Wikipédia sont
|
||||||
|
sous licence CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/deed.fr).
|
||||||
Generated
+2140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "jwe",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node build",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.1.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"maplibre-gl": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b3d2e" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||||
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { MAX_BOUNDS, MIN_ZOOM, REGION_BOUNDS, REGIONS } from '$lib/map/regions';
|
||||||
|
import styleOki from '$lib/map/style-oki.json';
|
||||||
|
import type { Coordonnees, Region } from '$lib/types';
|
||||||
|
import type { Map as MLMap, Marker, StyleSpecification } from 'maplibre-gl';
|
||||||
|
|
||||||
|
interface Paire {
|
||||||
|
from: Coordonnees;
|
||||||
|
to: Coordonnees;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarqueurInfo {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
guess?: Coordonnees | null;
|
||||||
|
real?: Coordonnees | null;
|
||||||
|
interactive?: boolean;
|
||||||
|
pairs?: Paire[];
|
||||||
|
markers?: MarqueurInfo[];
|
||||||
|
onguess?: (c: Coordonnees) => void;
|
||||||
|
onvalidate?: () => void;
|
||||||
|
onmarkerclick?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
guess = $bindable(null),
|
||||||
|
real = null,
|
||||||
|
interactive = true,
|
||||||
|
pairs = [],
|
||||||
|
markers = [],
|
||||||
|
onguess,
|
||||||
|
onvalidate,
|
||||||
|
onmarkerclick
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let conteneur: HTMLDivElement;
|
||||||
|
let map: MLMap | null = null;
|
||||||
|
let ml: typeof import('maplibre-gl') | null = null;
|
||||||
|
let guessMarker: Marker | null = null;
|
||||||
|
let realMarker: Marker | null = null;
|
||||||
|
let explorerMarkers: Marker[] = [];
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const SRC_LIGNE = 'jwe-ligne';
|
||||||
|
const SRC_PAIRES = 'jwe-paires';
|
||||||
|
|
||||||
|
const reducedMotion =
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
function styleUrl(): string | StyleSpecification {
|
||||||
|
// Bascule vers un tileserver auto-hébergé sans changement de code.
|
||||||
|
if (env.PUBLIC_TILES_URL) return env.PUBLIC_TILES_URL;
|
||||||
|
return styleOki as unknown as StyleSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSource(id: string): void {
|
||||||
|
if (map && !map.getSource(id)) {
|
||||||
|
map.addSource(id, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGeoJSON(id: string, data: GeoJSON.FeatureCollection): void {
|
||||||
|
const src = map?.getSource(id) as import('maplibre-gl').GeoJSONSource | undefined;
|
||||||
|
src?.setData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ligneFeature(from: Coordonnees, to: Coordonnees, props: GeoJSON.GeoJsonProperties): GeoJSON.Feature {
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: props,
|
||||||
|
geometry: { type: 'LineString', coordinates: [[from.lon, from.lat], [to.lon, to.lat]] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointFeature(c: Coordonnees, props: GeoJSON.GeoJsonProperties): GeoJSON.Feature {
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: props,
|
||||||
|
geometry: { type: 'Point', coordinates: [c.lon, c.lat] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dessinerRevelation(): void {
|
||||||
|
if (!map || !ml || !real) return;
|
||||||
|
ensureSource(SRC_LIGNE);
|
||||||
|
if (!map.getLayer('jwe-ligne-line')) {
|
||||||
|
map.addLayer({
|
||||||
|
id: 'jwe-ligne-line',
|
||||||
|
type: 'line',
|
||||||
|
source: SRC_LIGNE,
|
||||||
|
paint: { 'line-color': '#e4572e', 'line-width': 3, 'line-dasharray': [2, 1.5] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const features: GeoJSON.Feature[] = guess ? [ligneFeature(guess, real, null)] : [];
|
||||||
|
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features });
|
||||||
|
|
||||||
|
// Marqueur vert = position réelle
|
||||||
|
realMarker?.remove();
|
||||||
|
realMarker = new ml.Marker({ color: '#0b3d2e' }).setLngLat([real.lon, real.lat]).addTo(map);
|
||||||
|
|
||||||
|
// Le marqueur du joueur « vole » vers la position réelle (sauf reduced-motion)
|
||||||
|
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(
|
||||||
|
[Math.min(from.lon, real.lon), Math.min(from.lat, real.lat)],
|
||||||
|
[Math.max(from.lon, real.lon), Math.max(from.lat, real.lat)]
|
||||||
|
);
|
||||||
|
map.fitBounds(bounds, { padding: 80, duration: reducedMotion ? 0 : 900, maxZoom: 12 });
|
||||||
|
} else {
|
||||||
|
map.easeTo({ center: [real.lon, real.lat], zoom: 11, duration: reducedMotion ? 0 : 900 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nettoyerRevelation(): void {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
realMarker?.remove();
|
||||||
|
realMarker = null;
|
||||||
|
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGuessMarker(): void {
|
||||||
|
if (!map || !ml) return;
|
||||||
|
if (guess) {
|
||||||
|
if (!guessMarker) {
|
||||||
|
guessMarker = new ml.Marker({ color: '#e4572e', draggable: true })
|
||||||
|
.setLngLat([guess.lon, guess.lat])
|
||||||
|
.addTo(map);
|
||||||
|
guessMarker.on('dragend', () => {
|
||||||
|
const ll = guessMarker?.getLngLat();
|
||||||
|
if (ll) {
|
||||||
|
guess = { lat: ll.lat, lon: ll.lng };
|
||||||
|
onguess?.(guess);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
guessMarker.setLngLat([guess.lon, guess.lat]);
|
||||||
|
}
|
||||||
|
guessMarker.setDraggable(interactive && !real);
|
||||||
|
} else {
|
||||||
|
guessMarker?.remove();
|
||||||
|
guessMarker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPaires(): void {
|
||||||
|
if (!map || !ml) return;
|
||||||
|
ensureSource(SRC_PAIRES);
|
||||||
|
if (!map.getLayer('jwe-paires-line')) {
|
||||||
|
map.addLayer({
|
||||||
|
id: 'jwe-paires-line',
|
||||||
|
type: 'line',
|
||||||
|
source: SRC_PAIRES,
|
||||||
|
filter: ['==', ['geometry-type'], 'LineString'],
|
||||||
|
paint: { 'line-color': '#5a6b62', 'line-width': 2, 'line-dasharray': [2, 1.5] }
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'jwe-paires-points',
|
||||||
|
type: 'circle',
|
||||||
|
source: SRC_PAIRES,
|
||||||
|
filter: ['==', ['geometry-type'], 'Point'],
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 7,
|
||||||
|
'circle-color': ['get', 'couleur'],
|
||||||
|
'circle-stroke-color': '#fffdf8',
|
||||||
|
'circle-stroke-width': 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const features: GeoJSON.Feature[] = [];
|
||||||
|
for (const p of pairs) {
|
||||||
|
features.push(ligneFeature(p.from, p.to, null));
|
||||||
|
features.push(pointFeature(p.from, { couleur: '#e4572e' })); // joueur
|
||||||
|
features.push(pointFeature(p.to, { couleur: '#0b3d2e' })); // réel
|
||||||
|
}
|
||||||
|
setGeoJSON(SRC_PAIRES, { type: 'FeatureCollection', features });
|
||||||
|
if (pairs.length > 0) {
|
||||||
|
const b = new ml.LngLatBounds();
|
||||||
|
for (const p of pairs) {
|
||||||
|
b.extend([p.from.lon, p.from.lat]);
|
||||||
|
b.extend([p.to.lon, p.to.lat]);
|
||||||
|
}
|
||||||
|
map.fitBounds(b, { padding: 60, duration: reducedMotion ? 0 : 800 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncExplorerMarkers(): void {
|
||||||
|
if (!map || !ml) return;
|
||||||
|
for (const m of explorerMarkers) m.remove();
|
||||||
|
explorerMarkers = [];
|
||||||
|
for (const info of markers) {
|
||||||
|
const el = document.createElement('button');
|
||||||
|
el.type = 'button';
|
||||||
|
el.className = 'marqueur-explorer';
|
||||||
|
el.setAttribute('aria-label', info.label);
|
||||||
|
const mk = new ml.Marker({ element: el, color: '#175e70' })
|
||||||
|
.setLngLat([info.lon, info.lat])
|
||||||
|
.addTo(map!);
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onmarkerclick?.(info.id);
|
||||||
|
});
|
||||||
|
explorerMarkers.push(mk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sauterVers(region: Region): void {
|
||||||
|
const b = REGION_BOUNDS[region];
|
||||||
|
map?.fitBounds(
|
||||||
|
[
|
||||||
|
[b[0], b[1]],
|
||||||
|
[b[2], b[3]]
|
||||||
|
],
|
||||||
|
{ duration: reducedMotion ? 0 : 800 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
|
if (!map) return;
|
||||||
|
const pas = 140; // pixels de déplacement
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onvalidate?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dirs: Record<string, [number, number]> = {
|
||||||
|
ArrowUp: [0, -pas],
|
||||||
|
ArrowDown: [0, pas],
|
||||||
|
ArrowLeft: [-pas, 0],
|
||||||
|
ArrowRight: [pas, 0]
|
||||||
|
};
|
||||||
|
const d = dirs[e.key];
|
||||||
|
if (!d) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (guessMarker && interactive && !real) {
|
||||||
|
// Déplacer le marqueur : conversion écran → géo pour garder un pas constant
|
||||||
|
const ll = guessMarker.getLngLat();
|
||||||
|
const pt = map.project([ll.lng, ll.lat]);
|
||||||
|
const nv = map.unproject([pt.x + d[0], pt.y + d[1]]);
|
||||||
|
guess = { lat: nv.lat, lon: nv.lng };
|
||||||
|
onguess?.(guess);
|
||||||
|
} else {
|
||||||
|
map.panBy(d, { duration: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let detruit = false;
|
||||||
|
// Lazy load : MapLibre (JS + CSS) n'est chargé que sur les écrans avec carte.
|
||||||
|
void (async () => {
|
||||||
|
const [mod] = await Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]);
|
||||||
|
if (detruit) return;
|
||||||
|
ml = mod;
|
||||||
|
map = new mod.Map({
|
||||||
|
container: conteneur,
|
||||||
|
style: styleUrl(),
|
||||||
|
center: [-58, 0],
|
||||||
|
zoom: 3.6,
|
||||||
|
minZoom: MIN_ZOOM,
|
||||||
|
maxBounds: [
|
||||||
|
[MAX_BOUNDS[0], MAX_BOUNDS[1]],
|
||||||
|
[MAX_BOUNDS[2], MAX_BOUNDS[3]]
|
||||||
|
],
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
// Libellés FR/créole des contrôles natifs (sinon « Zoom in/out » en anglais)
|
||||||
|
locale: {
|
||||||
|
'NavigationControl.ZoomIn': t().carte.zoomIn,
|
||||||
|
'NavigationControl.ZoomOut': t().carte.zoomOut
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addControl(new mod.NavigationControl({ showCompass: false }), 'top-left');
|
||||||
|
map.on('click', (e) => {
|
||||||
|
if (!interactive || real) return;
|
||||||
|
guess = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
||||||
|
onguess?.(guess);
|
||||||
|
});
|
||||||
|
map.on('load', () => {
|
||||||
|
syncGuessMarker();
|
||||||
|
syncPaires();
|
||||||
|
syncExplorerMarkers();
|
||||||
|
if (real) dessinerRevelation();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
detruit = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
map?.remove();
|
||||||
|
map = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let ancienReal: Coordonnees | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
if (!map || !map.loaded()) return;
|
||||||
|
if (real && !ancienReal) dessinerRevelation();
|
||||||
|
if (!real && ancienReal) nettoyerRevelation();
|
||||||
|
ancienReal = real;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (map?.loaded()) syncGuessMarker();
|
||||||
|
void guess;
|
||||||
|
void interactive;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (map?.loaded()) syncPaires();
|
||||||
|
void pairs;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (map?.loaded()) syncExplorerMarkers();
|
||||||
|
void markers;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="carte-wrap">
|
||||||
|
<!-- La zone carte est volontairement focusable : flèches = déplacer le marqueur, Entrée = valider -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="carte"
|
||||||
|
bind:this={conteneur}
|
||||||
|
role="application"
|
||||||
|
aria-label={t().carte.ariaLabel}
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
></div>
|
||||||
|
<div class="sauts" role="group" aria-label={t().carte.sautRegion}>
|
||||||
|
{#each REGIONS as region (region)}
|
||||||
|
<button type="button" class="saut" onclick={() => sauterVers(region)}>
|
||||||
|
{t().regions[region]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.carte-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sauts {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saut {
|
||||||
|
background: rgba(255, 253, 248, 0.94);
|
||||||
|
color: var(--oki-vert);
|
||||||
|
border: 1px solid var(--oki-sable-fonce);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saut:hover {
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.marqueur-explorer) {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid var(--oki-blanc);
|
||||||
|
background: var(--oki-bleu);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.marqueur-explorer:hover),
|
||||||
|
:global(.marqueur-explorer:focus-visible) {
|
||||||
|
background: var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { getLang, setLang, type Lang } from '$lib/i18n/store.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="lang-switch" role="group" aria-label="Langue / Lang">
|
||||||
|
{#each ['fr', 'gcf'] as l (l)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:actif={getLang() === l}
|
||||||
|
aria-pressed={getLang() === l}
|
||||||
|
onclick={() => setLang(l as Lang)}
|
||||||
|
>
|
||||||
|
{t().langue[l as Lang]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.actif {
|
||||||
|
background: var(--oki-vert);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photo: Photo;
|
||||||
|
revealed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { photo, revealed = false }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<figure class="photo-panel">
|
||||||
|
<img src={photo.url} alt={photo.alt} loading="lazy" decoding="async" />
|
||||||
|
{#if revealed}
|
||||||
|
<figcaption>{photo.credit}</figcaption>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.photo-panel {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--oki-vert);
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import WikiFiche from './WikiFiche.svelte';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import type { GuessResult } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: GuessResult;
|
||||||
|
labelBouton?: string;
|
||||||
|
creditPhoto?: string;
|
||||||
|
onsuivant?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { result, labelBouton, creditPhoto, onsuivant }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="resultat" aria-live="polite">
|
||||||
|
<header class="score-bloc">
|
||||||
|
<p class="bravo">{result.score > 0 ? t().micro.bravo : t().micro.kiteSaYe}</p>
|
||||||
|
<p class="score">
|
||||||
|
<span class="valeur">{result.score}</span>
|
||||||
|
<span class="unite">{t().resultat.points}</span>
|
||||||
|
</p>
|
||||||
|
{#if result.distanceKm !== null}
|
||||||
|
<p class="distance">{t().resultat.distance} : {result.distanceKm} {t().resultat.km}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="lieu">
|
||||||
|
<strong>{result.nom}</strong>
|
||||||
|
{#if result.nom_creole && result.nom_creole !== result.nom}
|
||||||
|
<span class="creole"> · {result.nom_creole}</span>
|
||||||
|
{/if}
|
||||||
|
<br />
|
||||||
|
{t().resultat.commune} : {result.commune} — {t().regions[result.region]}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#key result.nom}
|
||||||
|
<WikiFiche
|
||||||
|
nom={result.nom}
|
||||||
|
wikidataId={result.wikidata_id}
|
||||||
|
tonPositif={result.score > 0 ? t().resultat.trouve : t().resultat.aDecouvrir}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
{#if creditPhoto}
|
||||||
|
<p class="credit-photo">{creditPhoto}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if onsuivant}
|
||||||
|
<button type="button" class="btn btn-primaire suivant" onclick={onsuivant}>
|
||||||
|
{labelBouton ?? t().resultat.suivant}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.resultat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bloc {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bravo {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score .valeur {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score .unite {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distance {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-photo {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lieu {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creole {
|
||||||
|
color: var(--oki-vert-clair);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suivant {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dureeMs?: number; // fenêtre du bonus temps (60 s)
|
||||||
|
actif?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { dureeMs = 60000, actif = true }: Props = $props();
|
||||||
|
|
||||||
|
let ecoule = $state(0);
|
||||||
|
let intervalle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const depart = Date.now();
|
||||||
|
|
||||||
|
// Fraction restante, de 1 (plein) à 0 (vide) — informatif, jamais anxiogène.
|
||||||
|
let fraction = $derived(Math.max(0, 1 - ecoule / dureeMs));
|
||||||
|
let termine = $derived(ecoule >= dureeMs);
|
||||||
|
|
||||||
|
const R = 15.5;
|
||||||
|
const C = 2 * Math.PI * R;
|
||||||
|
|
||||||
|
/** Temps écoulé en ms (pour le bonus côté /api/guess). */
|
||||||
|
export function tempsMs(): number {
|
||||||
|
return Date.now() - depart;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (actif) {
|
||||||
|
intervalle = setInterval(() => {
|
||||||
|
ecoule = Date.now() - depart;
|
||||||
|
if (ecoule >= dureeMs && intervalle) {
|
||||||
|
clearInterval(intervalle);
|
||||||
|
intervalle = null;
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (intervalle) clearInterval(intervalle);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="timer" class:termine role="timer" aria-label="Minuteur" title="Minuteur">
|
||||||
|
<svg viewBox="0 0 36 36" aria-hidden="true">
|
||||||
|
<circle class="fond" cx="18" cy="18" r={R} />
|
||||||
|
<circle
|
||||||
|
class="reste"
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r={R}
|
||||||
|
stroke-dasharray={C}
|
||||||
|
stroke-dashoffset={C * (1 - fraction)}
|
||||||
|
transform="rotate(-90 18 18)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timer {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fond {
|
||||||
|
fill: rgba(255, 253, 248, 0.85);
|
||||||
|
stroke: var(--oki-sable-fonce);
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reste {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--oki-vert-clair); /* couleur douce, calm technology */
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.25s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.termine .reste {
|
||||||
|
stroke: var(--oki-sable-fonce);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import type { WikiSummary } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nom: string;
|
||||||
|
wikidataId: string | null;
|
||||||
|
tonPositif?: string;
|
||||||
|
tonNeutre?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { nom, wikidataId, tonPositif, tonNeutre }: Props = $props();
|
||||||
|
|
||||||
|
// undefined = chargement, null = pas de fiche (Cas B), WikiSummary = Cas A
|
||||||
|
let wiki = $state<WikiSummary | null | undefined>(undefined);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!wikidataId) {
|
||||||
|
wiki = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let annule = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/wiki/${wikidataId}`);
|
||||||
|
if (annule) return;
|
||||||
|
wiki = res.ok ? ((await res.json()) as WikiSummary) : null;
|
||||||
|
} catch {
|
||||||
|
if (!annule) wiki = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
annule = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const lienContribution = $derived(
|
||||||
|
`https://fr.wikipedia.org/w/index.php?search=${encodeURIComponent(nom)}&title=Sp%C3%A9cial:Recherche`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if wiki === undefined}
|
||||||
|
<div class="panneau chargement">
|
||||||
|
<p>{t().explorer.chargerFiche}</p>
|
||||||
|
</div>
|
||||||
|
{:else if wiki}
|
||||||
|
<!-- Cas A : fiche Wikipédia existante -->
|
||||||
|
<article class="panneau cas-a">
|
||||||
|
<h2>
|
||||||
|
<a href={wiki.url} target="_blank" rel="noopener noreferrer">{wiki.title}</a>
|
||||||
|
</h2>
|
||||||
|
{#if tonPositif || tonNeutre}
|
||||||
|
<p class="ton">{tonPositif ?? tonNeutre}</p>
|
||||||
|
{/if}
|
||||||
|
{#if wiki.thumbnail}
|
||||||
|
<img src={wiki.thumbnail} alt="" loading="lazy" decoding="async" />
|
||||||
|
{/if}
|
||||||
|
<p class="extrait">{wiki.extract}</p>
|
||||||
|
<a class="lien-wiki" href={wiki.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{t().resultat.lireWiki} ↗
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<!-- Cas B : lacune documentaire — panneau volontairement distinct (Peak-End) -->
|
||||||
|
<article class="panneau cas-b">
|
||||||
|
<h2><span class="icone" aria-hidden="true">◌</span> {t().lacune.titre}</h2>
|
||||||
|
<p class="texte">{t().lacune.texte}</p>
|
||||||
|
<a class="btn btn-cta" href={lienContribution} target="_blank" rel="noopener noreferrer">
|
||||||
|
{t().lacune.cta} ↗
|
||||||
|
</a>
|
||||||
|
<p class="guide">
|
||||||
|
{t().lacune.guide}
|
||||||
|
<a href="https://fr.wikipedia.org/wiki/Aide:Premiers_pas" target="_blank" rel="noopener noreferrer">
|
||||||
|
{t().lacune.guideLien} ↗
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panneau {
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chargement {
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cas A : vert doux, apaisé */
|
||||||
|
.cas-a {
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
border: 1.5px solid var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-a h2 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-a h2 a {
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-a .ton {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-a img {
|
||||||
|
float: right;
|
||||||
|
width: 96px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 0 0.5rem 0.8rem;
|
||||||
|
border-radius: var(--oki-radius-petit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-a .extrait {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lien-wiki {
|
||||||
|
color: var(--oki-bleu);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cas B : teinte sable/orangée distincte, icône spécifique */
|
||||||
|
.cas-b {
|
||||||
|
background: var(--oki-lacune-fond);
|
||||||
|
border: 2px solid var(--oki-lacune-bord);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-b h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #8a5a00;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-b .icone {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border: 2.5px dashed var(--oki-lacune-bord);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cas-b .texte {
|
||||||
|
margin: 0 0 0.9rem;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cta {
|
||||||
|
background: var(--oki-lacune-bord);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cta:hover {
|
||||||
|
background: #b98a2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide {
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide a {
|
||||||
|
color: var(--oki-bleu);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const fr = {
|
||||||
|
app: {
|
||||||
|
titre: 'JWE',
|
||||||
|
sousTitre: 'Jeu de géolocalisation — Guadeloupe, Martinique, Guyane, La Réunion'
|
||||||
|
},
|
||||||
|
regions: {
|
||||||
|
GUADELOUPE: 'Guadeloupe',
|
||||||
|
MARTINIQUE: 'Martinique',
|
||||||
|
GUYANE: 'Guyane',
|
||||||
|
REUNION: 'La Réunion',
|
||||||
|
TOUTES: 'Toutes les régions'
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
monument: 'Konnèt moniman',
|
||||||
|
monumentDesc: 'Reconnais le monument photographié et place-le sur la carte.',
|
||||||
|
lieu: 'Kote mwen ye ?',
|
||||||
|
lieuDesc: 'Une rue, un bord de mer, une forêt… devine où tu es.',
|
||||||
|
defi: 'Défi 5 rounds',
|
||||||
|
defiDesc: '5 lieux d’affilée, monuments et lieux mélangés.',
|
||||||
|
explorer: 'Aprann',
|
||||||
|
explorerDesc: 'Explore la carte librement et lis les fiches Wikipédia. Sans score.'
|
||||||
|
},
|
||||||
|
accueil: {
|
||||||
|
choixRegion: 'Choisis ta région',
|
||||||
|
choixMode: 'Choisis ton mode',
|
||||||
|
options: 'Options',
|
||||||
|
timer: 'Minuteur (60 s, bonus de score)',
|
||||||
|
jouer: 'Jouer'
|
||||||
|
},
|
||||||
|
jeu: {
|
||||||
|
charger: 'Chargement du lieu…',
|
||||||
|
erreur: 'Impossible de charger un lieu. Réessaie.',
|
||||||
|
reessayer: 'Réessayer',
|
||||||
|
valider: 'Valider ma position',
|
||||||
|
passer: 'Passer',
|
||||||
|
indice: 'Indice',
|
||||||
|
indicePenalite: '−10 % de score',
|
||||||
|
indiceRestant: 'indice restant',
|
||||||
|
indicesRestants: 'indices restants',
|
||||||
|
plusDIndice: 'Plus d’indice disponible',
|
||||||
|
poserMarqueur: 'Clique sur la carte pour placer ton marqueur',
|
||||||
|
round: 'Manche',
|
||||||
|
sur: 'sur',
|
||||||
|
difficulte: {
|
||||||
|
FACILE: 'Facile',
|
||||||
|
MOYEN: 'Moyen',
|
||||||
|
DIFFICILE: 'Difficile',
|
||||||
|
EXPERT: 'Expert'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resultat: {
|
||||||
|
bravo: 'Bravo !',
|
||||||
|
score: 'Score',
|
||||||
|
points: 'points',
|
||||||
|
distance: 'Distance',
|
||||||
|
km: 'km',
|
||||||
|
trouve: 'Tu as trouvé ! Apprends-en plus sur ce lieu.',
|
||||||
|
aDecouvrir: 'Le lieu était ici. Apprends-en plus sur lui.',
|
||||||
|
lireWiki: 'Lire la suite sur Wikipédia',
|
||||||
|
commune: 'Commune',
|
||||||
|
region: 'Région',
|
||||||
|
suivant: 'Lieu suivant',
|
||||||
|
voirBilan: 'Voir le bilan',
|
||||||
|
rejouer: 'Rejouer',
|
||||||
|
retourAccueil: 'Retour à l’accueil'
|
||||||
|
},
|
||||||
|
lacune: {
|
||||||
|
titre: 'Pwen blindé — Sa pa dokimanté',
|
||||||
|
texte:
|
||||||
|
'Ce lieu existe, mais il n’a pas de page sur Wikipédia ni de fiche sur Wikidata. Pour nos régions, c’est un enjeu majeur : si nous ne documentons pas nos propres territoires, notre histoire et notre géographie disparaissent des cartes numériques et des bases de données mondiales (qui alimentent même l’intelligence artificielle).',
|
||||||
|
cta: 'Kontribyé sou Wikipédia',
|
||||||
|
guide: 'Besoin d’aide pour démarrer ? Suis le guide OKI « Comment contribuer à Wikipédia ».',
|
||||||
|
guideLien: 'Ouvrir le guide de contribution'
|
||||||
|
},
|
||||||
|
defi: {
|
||||||
|
bilan: 'Bilan du défi',
|
||||||
|
scoreTotal: 'Score total',
|
||||||
|
partager: 'Partager',
|
||||||
|
telecharger: 'Télécharger l’image',
|
||||||
|
taPosition: 'Ta position',
|
||||||
|
positionReelle: 'Position réelle',
|
||||||
|
manche: 'Manche'
|
||||||
|
},
|
||||||
|
explorer: {
|
||||||
|
titre: 'Aprann — Explorer',
|
||||||
|
filtrer: 'Filtrer par région',
|
||||||
|
chargerFiche: 'Chargement de la fiche…',
|
||||||
|
aucuneFiche: 'Sélectionne un marqueur sur la carte.',
|
||||||
|
fermer: 'Fermer le panneau'
|
||||||
|
},
|
||||||
|
carte: {
|
||||||
|
ariaLabel: 'Carte de jeu. Flèches pour déplacer le marqueur, Entrée pour valider.',
|
||||||
|
sautRegion: 'Aller à la région',
|
||||||
|
zoomIn: 'Zoomer',
|
||||||
|
zoomOut: 'Dézoomer'
|
||||||
|
},
|
||||||
|
micro: {
|
||||||
|
bravo: 'Bravo !',
|
||||||
|
kiteSaYe: 'Kité sa ye ?',
|
||||||
|
pwenBlinde: 'Pwen blindé'
|
||||||
|
},
|
||||||
|
langue: {
|
||||||
|
fr: 'Français',
|
||||||
|
gcf: 'Kreyòl'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fr;
|
||||||
|
export type Dict = typeof fr;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Dict } from './fr';
|
||||||
|
|
||||||
|
const gcf: Dict = {
|
||||||
|
app: {
|
||||||
|
titre: 'JWE',
|
||||||
|
sousTitre: 'Jwé jéyolokalizasyon — Gwadloup, Matinik, Giyan, Léryinyon'
|
||||||
|
},
|
||||||
|
regions: {
|
||||||
|
GUADELOUPE: 'Gwadloup',
|
||||||
|
MARTINIQUE: 'Matinik',
|
||||||
|
GUYANE: 'Giyan',
|
||||||
|
REUNION: 'Léryinyon',
|
||||||
|
TOUTES: 'Tout péyi yo'
|
||||||
|
},
|
||||||
|
modes: {
|
||||||
|
monument: 'Konnèt moniman',
|
||||||
|
monumentDesc: 'Roukonèt moniman-an asou foto-a é mété’y asou kat-la.',
|
||||||
|
lieu: 'Kote mwen ye ?',
|
||||||
|
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é.',
|
||||||
|
explorer: 'Aprann',
|
||||||
|
explorerDesc: 'Flanné asou kat-la é li fich Wikipédia yo. San pwen.'
|
||||||
|
},
|
||||||
|
accueil: {
|
||||||
|
choixRegion: 'Chwazi péyi ou',
|
||||||
|
choixMode: 'Chwazi jan ou vlé jwé',
|
||||||
|
options: 'Règlaj',
|
||||||
|
timer: 'Kronomèt (60 s, bonus pwen)',
|
||||||
|
jouer: 'Jwé'
|
||||||
|
},
|
||||||
|
jeu: {
|
||||||
|
charger: 'Nou ka chajé koté-la…',
|
||||||
|
erreur: 'Nou pa rivé chajé on koté. Éséyé ankò.',
|
||||||
|
reessayer: 'Éséyé ankò',
|
||||||
|
valider: 'Validé pozisyon mwen',
|
||||||
|
passer: 'Pasé',
|
||||||
|
indice: 'Endis',
|
||||||
|
indicePenalite: '−10 % pwen',
|
||||||
|
indiceRestant: 'endis ki rété',
|
||||||
|
indicesRestants: 'endis ki rété',
|
||||||
|
plusDIndice: 'Pa ni endis ankò',
|
||||||
|
poserMarqueur: 'Kwiké asou kat-la pou mété makò-a',
|
||||||
|
round: 'Mannèk',
|
||||||
|
sur: 'asou',
|
||||||
|
difficulte: {
|
||||||
|
FACILE: 'Fasil',
|
||||||
|
MOYEN: 'Mwayen',
|
||||||
|
DIFFICILE: 'Difisil',
|
||||||
|
EXPERT: 'Espè'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resultat: {
|
||||||
|
bravo: 'Bravo !',
|
||||||
|
score: 'Pwen',
|
||||||
|
points: 'pwen',
|
||||||
|
distance: 'Distans',
|
||||||
|
km: 'km',
|
||||||
|
trouve: 'Ou trouvé ! Aprann pli plis asou koté sa-a.',
|
||||||
|
aDecouvrir: 'Koté-la té la. Aprann pli plis asou’y.',
|
||||||
|
lireWiki: 'Li swit-la asou Wikipédia',
|
||||||
|
commune: 'Komin',
|
||||||
|
region: 'Péyi',
|
||||||
|
suivant: 'Koté swivan',
|
||||||
|
voirBilan: 'Wè bilan-an',
|
||||||
|
rejouer: 'Rèjwé',
|
||||||
|
retourAccueil: 'Tounen an lakay'
|
||||||
|
},
|
||||||
|
lacune: {
|
||||||
|
titre: 'Pwen blindé — Sa pa dokimanté',
|
||||||
|
texte:
|
||||||
|
'Koté sa-a égzisté, men i pa ni on paj asou Wikipédia ni on fich asou Wikidata. Pou péyi nou, sé on zafè enpòtan anpil : si nou pa dokimanté pwòp téritwa nou, listwa épi jéwografi nou kay disparèt asou kat nimérik épi labaz donné lemonn yo (ki manm ka bay manjé a lintelijans atifisyèl).',
|
||||||
|
cta: 'Kontribyé sou Wikipédia',
|
||||||
|
guide: 'Ou bizwen èd pou koumansé ? Swiv gid OKI « Kijan pou kontribyé sou Wikipédia ».',
|
||||||
|
guideLien: 'Ouvri gid kontribisyon-an'
|
||||||
|
},
|
||||||
|
defi: {
|
||||||
|
bilan: 'Bilan défi-a',
|
||||||
|
scoreTotal: 'Total pwen',
|
||||||
|
partager: 'Palé di moun',
|
||||||
|
telecharger: 'Téléchajé foto-a',
|
||||||
|
taPosition: 'Pozisyon ou',
|
||||||
|
positionReelle: 'Rèl pozisyon-an',
|
||||||
|
manche: 'Mannèk'
|
||||||
|
},
|
||||||
|
explorer: {
|
||||||
|
titre: 'Aprann — Flanné',
|
||||||
|
filtrer: 'Triyé pa péyi',
|
||||||
|
chargerFiche: 'Nou ka chajé fich-la…',
|
||||||
|
aucuneFiche: 'Kwiké asou on makò asou kat-la.',
|
||||||
|
fermer: 'Fèmé pano-a'
|
||||||
|
},
|
||||||
|
carte: {
|
||||||
|
ariaLabel: 'Kat jwé-a. Flèch yo pou déplasé makò-a, Antré pou validé.',
|
||||||
|
sautRegion: 'Alé o péyi',
|
||||||
|
zoomIn: 'Zoumé',
|
||||||
|
zoomOut: 'Dézoumé'
|
||||||
|
},
|
||||||
|
micro: {
|
||||||
|
bravo: 'Bravo !',
|
||||||
|
kiteSaYe: 'Kité sa ye ?',
|
||||||
|
pwenBlinde: 'Pwen blindé'
|
||||||
|
},
|
||||||
|
langue: {
|
||||||
|
fr: 'Français',
|
||||||
|
gcf: 'Kreyòl'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default gcf;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import fr, { type Dict } from './fr';
|
||||||
|
import gcf from './gcf';
|
||||||
|
|
||||||
|
export type Lang = 'fr' | 'gcf';
|
||||||
|
|
||||||
|
const dicts: Record<Lang, Dict> = { fr, gcf };
|
||||||
|
const STORAGE_KEY = 'jwe-lang';
|
||||||
|
|
||||||
|
let lang = $state<Lang>('fr');
|
||||||
|
|
||||||
|
/** Dictionnaire courant — fonction réactive : lit le `$state`, donc les templates se mettent à jour. */
|
||||||
|
export function t(): Dict {
|
||||||
|
return dicts[lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLang(): Lang {
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLang(next: Lang): void {
|
||||||
|
lang = next;
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
document.documentElement.lang = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À appeler une fois au montage du layout : restaure la langue persistée. */
|
||||||
|
export function initLang(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved === 'fr' || saved === 'gcf') {
|
||||||
|
setLang(saved);
|
||||||
|
} else {
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Region } from '$lib/types';
|
||||||
|
|
||||||
|
/** Bornes de chaque région : [lonOuest, latSud, lonEst, latNord]. */
|
||||||
|
export const REGION_BOUNDS: Record<Region, [number, number, number, number]> = {
|
||||||
|
GUADELOUPE: [-61.9, 15.8, -60.95, 16.55],
|
||||||
|
MARTINIQUE: [-61.25, 14.35, -60.75, 14.95],
|
||||||
|
GUYANE: [-54.7, 1.9, -51.5, 6.0],
|
||||||
|
REUNION: [55.15, -21.42, 55.9, -20.82]
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Enveloppe union des 4 régions (≈ lon −63…57, lat −23…18) : interdit de sortir du terrain de jeu. */
|
||||||
|
export const MAX_BOUNDS: [number, number, number, number] = [-63, -23, 57, 18];
|
||||||
|
|
||||||
|
export const MIN_ZOOM = 4;
|
||||||
|
|
||||||
|
export const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
|||||||
|
import rawLieux from './data/lieux.json';
|
||||||
|
import type {
|
||||||
|
Categorie,
|
||||||
|
Difficulte,
|
||||||
|
ExplorerLieu,
|
||||||
|
GuessResult,
|
||||||
|
Lieu,
|
||||||
|
Region,
|
||||||
|
RoundPlace
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
|
const lieux = rawLieux as Lieu[];
|
||||||
|
|
||||||
|
const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION'];
|
||||||
|
const CATEGORIES: Categorie[] = ['MONUMENT', 'LIEU'];
|
||||||
|
|
||||||
|
export const MAX_POINTS = 5000;
|
||||||
|
|
||||||
|
/** Rayon (km) de la courbe de score selon la difficulté du lieu. */
|
||||||
|
export const RAYONS: Record<Difficulte, number> = {
|
||||||
|
FACILE: 50,
|
||||||
|
MOYEN: 15,
|
||||||
|
DIFFICILE: 5,
|
||||||
|
EXPERT: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Distance haversine en km. */
|
||||||
|
export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||||
|
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
|
||||||
|
const s =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRegion(value: string | null): Region | null {
|
||||||
|
return value && REGIONS.includes(value as Region) ? (value as Region) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return lieux.find((l) => l.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélection aléatoire d'un lieu, avec filtres optionnels. */
|
||||||
|
export function getRandomLieu(options: {
|
||||||
|
region?: string | null;
|
||||||
|
categorie?: string | null;
|
||||||
|
exclude?: string | null;
|
||||||
|
}): Lieu | null {
|
||||||
|
const region = parseRegion(options.region ?? null);
|
||||||
|
const categorie = parseCategorie(options.categorie ?? null);
|
||||||
|
const excluded = new Set(
|
||||||
|
(options.exclude ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
let pool = lieux.filter((l) => !excluded.has(l.id));
|
||||||
|
if (region) pool = pool.filter((l) => l.region === region);
|
||||||
|
if (categorie) pool = pool.filter((l) => l.categorie === categorie);
|
||||||
|
// Si tout a été exclu, on réessaie sans les exclusions (le jeu doit continuer).
|
||||||
|
if (pool.length === 0) {
|
||||||
|
pool = lieux;
|
||||||
|
if (region) pool = pool.filter((l) => l.region === region);
|
||||||
|
if (categorie) pool = pool.filter((l) => l.categorie === categorie);
|
||||||
|
}
|
||||||
|
if (pool.length === 0) return null;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anti-triche : ne garde que les champs publiques pour /api/round. */
|
||||||
|
export function sanitizeRound(lieu: Lieu): RoundPlace {
|
||||||
|
return {
|
||||||
|
id: lieu.id,
|
||||||
|
nom: lieu.nom,
|
||||||
|
nom_creole: lieu.nom_creole,
|
||||||
|
categorie: lieu.categorie,
|
||||||
|
difficulte: lieu.difficulte,
|
||||||
|
photo: lieu.photo,
|
||||||
|
indices: lieu.indices
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoring recalculé côté serveur :
|
||||||
|
* score = round(5000 × e^(−d/r)), r selon difficulté ;
|
||||||
|
* pénalité −10 % par indice ; bonus temps ≤ +20 % ; total plafonné à 5000.
|
||||||
|
*/
|
||||||
|
export function scoreGuess(
|
||||||
|
lieu: Lieu,
|
||||||
|
guess: { lat: number; lon: number },
|
||||||
|
hintsUsed: number,
|
||||||
|
timeMs?: number
|
||||||
|
): GuessResult {
|
||||||
|
const distanceKm = haversineKm(guess, lieu.coordonnees);
|
||||||
|
const base = Math.round(MAX_POINTS * Math.exp(-distanceKm / RAYONS[lieu.difficulte]));
|
||||||
|
const penalises = base * Math.max(0, 1 - 0.1 * Math.max(0, hintsUsed));
|
||||||
|
let total = penalises;
|
||||||
|
if (typeof timeMs === 'number' && Number.isFinite(timeMs) && timeMs >= 0) {
|
||||||
|
total += penalises * 0.2 * Math.max(0, 1 - timeMs / 60000);
|
||||||
|
}
|
||||||
|
const score = Math.min(MAX_POINTS, Math.round(total));
|
||||||
|
return {
|
||||||
|
distanceKm: Math.round(distanceKm * 10) / 10,
|
||||||
|
score,
|
||||||
|
coordonnees: lieu.coordonnees,
|
||||||
|
commune: lieu.commune,
|
||||||
|
region: lieu.region,
|
||||||
|
nom: lieu.nom,
|
||||||
|
nom_creole: lieu.nom_creole,
|
||||||
|
wikidata_id: lieu.wikidata_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse « Passer » : la vérité est révélée, score 0, pas de distance. */
|
||||||
|
export function skipResult(lieu: Lieu): GuessResult {
|
||||||
|
return {
|
||||||
|
distanceKm: null,
|
||||||
|
score: 0,
|
||||||
|
coordonnees: lieu.coordonnees,
|
||||||
|
commune: lieu.commune,
|
||||||
|
region: lieu.region,
|
||||||
|
nom: lieu.nom,
|
||||||
|
nom_creole: lieu.nom_creole,
|
||||||
|
wikidata_id: lieu.wikidata_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalogue Explorer : coords arrondies à 0.05° (mode sans score, fiches autorisées). */
|
||||||
|
export function catalogueExplorer(): ExplorerLieu[] {
|
||||||
|
return lieux.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
nom: l.nom,
|
||||||
|
nom_creole: l.nom_creole,
|
||||||
|
region: l.region,
|
||||||
|
commune: l.commune,
|
||||||
|
coordonnees: {
|
||||||
|
lat: Math.round(l.coordonnees.lat / 0.05) * 0.05,
|
||||||
|
lon: Math.round(l.coordonnees.lon / 0.05) * 0.05
|
||||||
|
},
|
||||||
|
categorie: l.categorie,
|
||||||
|
difficulte: l.difficulte,
|
||||||
|
wikidata_id: l.wikidata_id,
|
||||||
|
photo: l.photo
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
|
||||||
|
|
||||||
|
export interface MancheDefi {
|
||||||
|
place: RoundPlace;
|
||||||
|
guess: Coordonnees | null;
|
||||||
|
result: GuessResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** État du Défi 5 rounds — client uniquement, sans session serveur. */
|
||||||
|
function creerDefiStore() {
|
||||||
|
let manches = $state<MancheDefi[]>([]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get manches() {
|
||||||
|
return manches;
|
||||||
|
},
|
||||||
|
get total() {
|
||||||
|
return manches.reduce((s, m) => s + m.result.score, 0);
|
||||||
|
},
|
||||||
|
get termine() {
|
||||||
|
return manches.length >= 5;
|
||||||
|
},
|
||||||
|
ajouter(manche: MancheDefi) {
|
||||||
|
manches = [...manches, manche];
|
||||||
|
},
|
||||||
|
reinitialiser() {
|
||||||
|
manches = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defi = creerDefiStore();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/* Tokens OKI — doctrine « calm technology » : couleurs ancrées dans le territoire,
|
||||||
|
un seul accent fort (corail) réservé à l'action primaire (Von Restorff). */
|
||||||
|
:root {
|
||||||
|
--oki-vert: #0b3d2e; /* vert profond — couleur principale de la marque */
|
||||||
|
--oki-vert-clair: #17624a;
|
||||||
|
--oki-vert-doux: #e3efe7;
|
||||||
|
--oki-sable: #f5efe2; /* fonds */
|
||||||
|
--oki-sable-fonce: #e7dcc4;
|
||||||
|
--oki-bleu: #175e70; /* bleu caraïbe profond */
|
||||||
|
--oki-bleu-doux: #d9ecef;
|
||||||
|
--oki-corail: #e4572e; /* accent — action primaire UNIQUEMENT */
|
||||||
|
--oki-corail-fonce: #c4441f;
|
||||||
|
--oki-encre: #22302a;
|
||||||
|
--oki-encre-doux: #5a6b62;
|
||||||
|
--oki-blanc: #fffdf8;
|
||||||
|
--oki-lacune-fond: #fdf3e3; /* panneau Cas B « Pwen blindé » — teinte distincte */
|
||||||
|
--oki-lacune-bord: #d9a441;
|
||||||
|
|
||||||
|
--oki-radius: 14px;
|
||||||
|
--oki-radius-petit: 9px;
|
||||||
|
--oki-ombre: 0 2px 10px rgba(11, 61, 46, 0.14);
|
||||||
|
--oki-font:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--oki-font);
|
||||||
|
color: var(--oki-encre);
|
||||||
|
background: var(--oki-sable);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* — Boutons — */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--oki-radius-petit);
|
||||||
|
padding: 0.85rem 1.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
[tabindex]:focus-visible {
|
||||||
|
outline: 3px solid var(--oki-bleu);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action primaire : le seul élément fortement coloré de l'écran */
|
||||||
|
.btn-primaire {
|
||||||
|
background: var(--oki-corail);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primaire:hover {
|
||||||
|
background: var(--oki-corail-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primaire:disabled {
|
||||||
|
background: var(--oki-sable-fonce);
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondaire {
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondaire:hover {
|
||||||
|
background: #d2e5d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tertiaire {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--oki-vert-clair);
|
||||||
|
border: 1.5px solid var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tertiaire:hover {
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type Region = 'GUADELOUPE' | 'MARTINIQUE' | 'GUYANE' | 'REUNION';
|
||||||
|
export type Categorie = 'MONUMENT' | 'LIEU';
|
||||||
|
export type Difficulte = 'FACILE' | 'MOYEN' | 'DIFFICILE' | 'EXPERT';
|
||||||
|
|
||||||
|
export interface Coordonnees {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Indice {
|
||||||
|
niveau: number;
|
||||||
|
texte: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Photo {
|
||||||
|
url: string;
|
||||||
|
credit: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lieu complet — serveur uniquement, jamais exposé tel quel au client. */
|
||||||
|
export interface Lieu {
|
||||||
|
id: string;
|
||||||
|
nom: string;
|
||||||
|
nom_creole: string;
|
||||||
|
region: Region;
|
||||||
|
commune: string;
|
||||||
|
coordonnees: Coordonnees;
|
||||||
|
categorie: Categorie;
|
||||||
|
difficulte: Difficulte;
|
||||||
|
wikidata_id: string | null;
|
||||||
|
photo: Photo;
|
||||||
|
indices: Indice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lieu SANITISÉ envoyé par GET /api/round (aucune coordonnée, commune, région, wikidata_id). */
|
||||||
|
export interface RoundPlace {
|
||||||
|
id: string;
|
||||||
|
nom: string;
|
||||||
|
nom_creole: string;
|
||||||
|
categorie: Categorie;
|
||||||
|
difficulte: Difficulte;
|
||||||
|
photo: Photo;
|
||||||
|
indices: Indice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse de POST /api/guess : la vérité n'est révélée qu'après soumission. */
|
||||||
|
export interface GuessResult {
|
||||||
|
distanceKm: number | null;
|
||||||
|
score: number;
|
||||||
|
coordonnees: Coordonnees;
|
||||||
|
commune: string;
|
||||||
|
region: Region;
|
||||||
|
nom: string;
|
||||||
|
nom_creole: string;
|
||||||
|
wikidata_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrée du catalogue Explorer : coords arrondies à 0.05°, mode sans score. */
|
||||||
|
export interface ExplorerLieu {
|
||||||
|
id: string;
|
||||||
|
nom: string;
|
||||||
|
nom_creole: string;
|
||||||
|
region: Region;
|
||||||
|
commune: string;
|
||||||
|
coordonnees: Coordonnees;
|
||||||
|
categorie: Categorie;
|
||||||
|
difficulte: Difficulte;
|
||||||
|
wikidata_id: string | null;
|
||||||
|
photo: Photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse normalisée de GET /api/wiki/[qid]. */
|
||||||
|
export interface WikiSummary {
|
||||||
|
qid: string;
|
||||||
|
title: string;
|
||||||
|
extract: string;
|
||||||
|
url: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameMode = 'monument' | 'lieu' | 'defi';
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import '$lib/styles/oki.css';
|
||||||
|
import LangSwitch from '$lib/components/LangSwitch.svelte';
|
||||||
|
import { initLang, t } from '$lib/i18n/store.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initLang();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header class="entete">
|
||||||
|
<a href="/" class="logo" aria-label="JWE — accueil">
|
||||||
|
<svg viewBox="0 0 32 32" width="30" height="30" aria-hidden="true">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="none" stroke="currentColor" stroke-width="2.5" />
|
||||||
|
<path d="M16 7 L21 22 L16 18.5 L11 22 Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span>{t().app.titre}</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-lang">
|
||||||
|
<a href="/explorer" class="lien-explorer">{t().modes.explorer}</a>
|
||||||
|
<LangSwitch />
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--oki-vert);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-lang {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lien-explorer {
|
||||||
|
color: var(--oki-vert-doux);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lien-explorer:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { REGIONS } from '$lib/map/regions';
|
||||||
|
import type { GameMode, Region } from '$lib/types';
|
||||||
|
|
||||||
|
type ChoixRegion = Region | 'TOUTES';
|
||||||
|
|
||||||
|
let region = $state<ChoixRegion>('TOUTES');
|
||||||
|
let mode = $state<GameMode>('monument');
|
||||||
|
let timer = $state(false);
|
||||||
|
|
||||||
|
const modes: { id: GameMode; emoji: string }[] = [
|
||||||
|
{ id: 'monument', emoji: '🏛️' },
|
||||||
|
{ id: 'lieu', emoji: '📍' },
|
||||||
|
{ id: 'defi', emoji: '⛰️' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function jouer(): void {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (region !== 'TOUTES') params.set('region', region);
|
||||||
|
if (timer) params.set('timer', '1');
|
||||||
|
goto(`/jeu/${mode}${params.size ? `?${params}` : ''}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t().app.titre} — {t().app.sousTitre}</title>
|
||||||
|
<meta name="description" content={t().app.sousTitre} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="accueil">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>{t().app.titre}</h1>
|
||||||
|
<p class="accroche">{t().micro.kiteSaYe}</p>
|
||||||
|
<p class="sous-titre">{t().app.sousTitre}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-regions">
|
||||||
|
<h2 id="titre-regions">{t().accueil.choixRegion}</h2>
|
||||||
|
<div class="grille regions">
|
||||||
|
{#each REGIONS as r (r)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carte-region region-{r.toLowerCase()}"
|
||||||
|
class:choisi={region === r}
|
||||||
|
aria-pressed={region === r}
|
||||||
|
onclick={() => (region = r)}
|
||||||
|
>
|
||||||
|
<span class="nom">{t().regions[r]}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carte-region toutes"
|
||||||
|
class:choisi={region === 'TOUTES'}
|
||||||
|
aria-pressed={region === 'TOUTES'}
|
||||||
|
onclick={() => (region = 'TOUTES')}
|
||||||
|
>
|
||||||
|
<span class="nom">{t().regions.TOUTES}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-modes">
|
||||||
|
<h2 id="titre-modes">{t().accueil.choixMode}</h2>
|
||||||
|
<div class="grille modes">
|
||||||
|
{#each modes as m (m.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carte-mode"
|
||||||
|
class:choisi={mode === m.id}
|
||||||
|
aria-pressed={mode === m.id}
|
||||||
|
onclick={() => (mode = m.id)}
|
||||||
|
>
|
||||||
|
<span class="emoji" aria-hidden="true">{m.emoji}</span>
|
||||||
|
<span class="nom">{t().modes[m.id]}</span>
|
||||||
|
<span class="desc">{t().modes[`${m.id}Desc`]}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<a href="/explorer" class="carte-mode explorer">
|
||||||
|
<span class="emoji" aria-hidden="true">🧭</span>
|
||||||
|
<span class="nom">{t().modes.explorer}</span>
|
||||||
|
<span class="desc">{t().modes.explorerDesc}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="options" aria-labelledby="titre-options">
|
||||||
|
<h2 id="titre-options">{t().accueil.options}</h2>
|
||||||
|
<label class="option-timer">
|
||||||
|
<input type="checkbox" bind:checked={timer} />
|
||||||
|
<span>{t().accueil.timer}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="zone-pouce">
|
||||||
|
<button type="button" class="btn btn-primaire jouer" onclick={jouer}>
|
||||||
|
{t().accueil.jouer}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.accueil {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1rem 7rem; /* espace pour le bouton fixe en zone pouce */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accroche {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--oki-vert-clair);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sous-titre {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grille {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regions {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-region,
|
||||||
|
.carte-mode {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--oki-encre);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-region {
|
||||||
|
padding: 1.4rem 0.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-region .nom {
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 1rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode .emoji {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode .nom {
|
||||||
|
font-weight: 750;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-mode .desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-region:hover,
|
||||||
|
.carte-mode:hover {
|
||||||
|
border-color: var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-region.choisi,
|
||||||
|
.carte-mode.choisi {
|
||||||
|
border-color: var(--oki-vert);
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-timer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-timer input {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
accent-color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton « Jouer » : fixe en zone du pouce sur mobile */
|
||||||
|
.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;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jouer {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 3.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getLieuById, scoreGuess, skipResult } from '$lib/server/lieux';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
interface GuessBody {
|
||||||
|
id?: unknown;
|
||||||
|
lat?: unknown;
|
||||||
|
lon?: unknown;
|
||||||
|
hintsUsed?: unknown;
|
||||||
|
timeMs?: unknown;
|
||||||
|
skip?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/guess { id, lat, lon, hintsUsed, timeMs? }
|
||||||
|
* → score recalculé serveur + vérité du lieu (révélée seulement ici).
|
||||||
|
* `skip: true` révèle la réponse avec un score de 0 (bouton « Passer »).
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
let body: GuessBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as GuessBody;
|
||||||
|
} catch {
|
||||||
|
return json({ erreur: 'Corps JSON invalide' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body.id !== 'string') {
|
||||||
|
return json({ erreur: 'id manquant' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const lieu = getLieuById(body.id);
|
||||||
|
if (!lieu) {
|
||||||
|
return json({ erreur: 'Lieu inconnu' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.skip === true) {
|
||||||
|
return json(skipResult(lieu));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = Number(body.lat);
|
||||||
|
const lon = Number(body.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) {
|
||||||
|
return json({ erreur: 'Coordonnées invalides' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const hintsUsed = Math.max(0, Math.floor(Number(body.hintsUsed) || 0));
|
||||||
|
const timeMs =
|
||||||
|
typeof body.timeMs === 'number' && Number.isFinite(body.timeMs) && body.timeMs >= 0
|
||||||
|
? body.timeMs
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return json(scoreGuess(lieu, { lat, lon }, hintsUsed, timeMs));
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { catalogueExplorer } from '$lib/server/lieux';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/** GET /api/lieux → catalogue Explorer (coords arrondies à 0.05°, mode sans score). */
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
return json(catalogueExplorer(), {
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=300' }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getRandomLieu, sanitizeRound } from '$lib/server/lieux';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/** GET /api/round?region=&categorie=&exclude=id1,id2 → lieu aléatoire SANITISÉ. */
|
||||||
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
const lieu = getRandomLieu({
|
||||||
|
region: url.searchParams.get('region'),
|
||||||
|
categorie: url.searchParams.get('categorie'),
|
||||||
|
exclude: url.searchParams.get('exclude')
|
||||||
|
});
|
||||||
|
if (!lieu) {
|
||||||
|
return json({ erreur: 'Aucun lieu disponible pour ces critères' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return json(sanitizeRound(lieu), {
|
||||||
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { WikiSummary } from '$lib/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 TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
||||||
|
|
||||||
|
const cache = new Map<string, { expires: number; summary: WikiSummary }>();
|
||||||
|
|
||||||
|
interface WbEntity {
|
||||||
|
labels?: { fr?: { value: string } };
|
||||||
|
sitelinks?: { frwiki?: { title: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WbResponse {
|
||||||
|
entities?: Record<string, WbEntity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestSummary {
|
||||||
|
title?: string;
|
||||||
|
extract?: string;
|
||||||
|
content_urls?: { desktop?: { page?: string } };
|
||||||
|
thumbnail?: { source?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/wiki/[qid] → extrait Wikipédia FR normalisé (404 si pas de sitelink fr). */
|
||||||
|
export const GET: RequestHandler = async ({ params, fetch }) => {
|
||||||
|
const qid = params.qid;
|
||||||
|
if (!/^Q\d+$/.test(qid)) {
|
||||||
|
return json({ erreur: 'Identifiant Wikidata invalide' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = cache.get(qid);
|
||||||
|
if (cached && cached.expires > Date.now()) {
|
||||||
|
return json(cached.summary, { headers: { 'Cache-Control': 'public, max-age=3600' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { 'User-Agent': USER_AGENT, Accept: 'application/json' };
|
||||||
|
|
||||||
|
// 1. Wikidata : sitelink frwiki (+ libellé fr en fallback)
|
||||||
|
const wbUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${encodeURIComponent(qid)}&props=sitelinks%7Clabels&sitefilter=frwiki&languages=fr&format=json`;
|
||||||
|
let entity: WbEntity | undefined;
|
||||||
|
try {
|
||||||
|
const res = await fetch(wbUrl, { headers });
|
||||||
|
if (!res.ok) throw new Error(`wikidata ${res.status}`);
|
||||||
|
entity = ((await res.json()) as WbResponse).entities?.[qid];
|
||||||
|
} catch {
|
||||||
|
return json({ erreur: 'Wikidata injoignable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const titre = entity?.sitelinks?.frwiki?.title;
|
||||||
|
if (!titre) {
|
||||||
|
// Pas de page Wikipédia FR → le front bascule sur l'écran « Pwen blindé ».
|
||||||
|
return json({ erreur: 'Pas de page Wikipédia en français' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wikipédia FR : résumé de l'article
|
||||||
|
let data: RestSummary;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://fr.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(titre)}`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
if (res.status === 404) return json({ erreur: 'Article introuvable' }, { status: 404 });
|
||||||
|
if (!res.ok) throw new Error(`wikipedia ${res.status}`);
|
||||||
|
data = (await res.json()) as RestSummary;
|
||||||
|
} catch {
|
||||||
|
return json({ erreur: 'Wikipédia injoignable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: WikiSummary = {
|
||||||
|
qid,
|
||||||
|
title: data.title ?? titre,
|
||||||
|
extract: data.extract ?? '',
|
||||||
|
url: data.content_urls?.desktop?.page ?? `https://fr.wikipedia.org/wiki/${encodeURIComponent(titre)}`,
|
||||||
|
thumbnail: data.thumbnail?.source ?? null
|
||||||
|
};
|
||||||
|
cache.set(qid, { expires: Date.now() + TTL_MS, summary });
|
||||||
|
|
||||||
|
return json(summary, { headers: { 'Cache-Control': 'public, max-age=3600' } });
|
||||||
|
};
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import GameMap from '$lib/components/GameMap.svelte';
|
||||||
|
import WikiFiche from '$lib/components/WikiFiche.svelte';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { REGIONS } from '$lib/map/regions';
|
||||||
|
import type { ExplorerLieu, Region } from '$lib/types';
|
||||||
|
|
||||||
|
type Filtre = Region | 'TOUTES';
|
||||||
|
|
||||||
|
let lieux = $state<ExplorerLieu[]>([]);
|
||||||
|
let filtre = $state<Filtre>('TOUTES');
|
||||||
|
let selection = $state<ExplorerLieu | null>(null);
|
||||||
|
let erreur = $state(false);
|
||||||
|
|
||||||
|
const lieuxFiltres = $derived(
|
||||||
|
filtre === 'TOUTES' ? lieux : lieux.filter((l) => l.region === filtre)
|
||||||
|
);
|
||||||
|
const markers = $derived(
|
||||||
|
lieuxFiltres.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
lat: l.coordonnees.lat,
|
||||||
|
lon: l.coordonnees.lon,
|
||||||
|
label: `${l.nom} — ${l.commune}`
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function ouvrir(id: string): void {
|
||||||
|
selection = lieux.find((l) => l.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/lieux');
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
lieux = (await res.json()) as ExplorerLieu[];
|
||||||
|
} catch {
|
||||||
|
erreur = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t().app.titre} — {t().explorer.titre}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="explorer">
|
||||||
|
<div class="filtres" role="group" aria-label={t().explorer.filtrer}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:actif={filtre === 'TOUTES'}
|
||||||
|
aria-pressed={filtre === 'TOUTES'}
|
||||||
|
onclick={() => (filtre = 'TOUTES')}
|
||||||
|
>
|
||||||
|
{t().regions.TOUTES}
|
||||||
|
</button>
|
||||||
|
{#each REGIONS as r (r)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:actif={filtre === r}
|
||||||
|
aria-pressed={filtre === r}
|
||||||
|
onclick={() => (filtre = r)}
|
||||||
|
>
|
||||||
|
{t().regions[r]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carte-explorer">
|
||||||
|
{#if erreur}
|
||||||
|
<p class="erreur">{t().jeu.erreur}</p>
|
||||||
|
{:else}
|
||||||
|
<GameMap interactive={false} {markers} onmarkerclick={ouvrir} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau latéral (desktop) / bottom sheet (mobile) -->
|
||||||
|
<aside class="fiche" class:ouvert={!!selection} aria-live="polite">
|
||||||
|
{#if selection}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fermer"
|
||||||
|
aria-label={t().explorer.fermer}
|
||||||
|
onclick={() => (selection = null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<figure class="fiche-photo">
|
||||||
|
<img
|
||||||
|
src={selection.photo.url}
|
||||||
|
alt={selection.photo.alt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<figcaption>{selection.photo.credit}</figcaption>
|
||||||
|
</figure>
|
||||||
|
<h1>{selection.nom}</h1>
|
||||||
|
{#if selection.nom_creole && selection.nom_creole !== selection.nom}
|
||||||
|
<p class="creole">{selection.nom_creole}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="localisation">
|
||||||
|
{t().resultat.commune} : {selection.commune} — {t().regions[selection.region]}
|
||||||
|
</p>
|
||||||
|
{#key selection.id}
|
||||||
|
<WikiFiche nom={selection.nom} wikidataId={selection.wikidata_id} />
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<p class="vide">{t().explorer.aucuneFiche}</p>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.explorer {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtres {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
border-bottom: 1px solid var(--oki-sable-fonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtres button {
|
||||||
|
border: 1.5px solid var(--oki-vert-clair);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtres button.actif {
|
||||||
|
background: var(--oki-vert);
|
||||||
|
border-color: var(--oki-vert);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-explorer {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erreur {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom sheet mobile */
|
||||||
|
.fiche {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 62%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
border-radius: var(--oki-radius) var(--oki-radius) 0 0;
|
||||||
|
box-shadow: 0 -4px 18px rgba(11, 61, 46, 0.2);
|
||||||
|
padding: 1rem;
|
||||||
|
transform: translateY(calc(100% - 3.2rem));
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche.ouvert {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche .vide {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fermer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
border: none;
|
||||||
|
background: var(--oki-vert-doux);
|
||||||
|
color: var(--oki-vert);
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-photo {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-photo img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--oki-radius-petit);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-photo figcaption {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--oki-vert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.creole {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--oki-vert-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
.localisation {
|
||||||
|
margin: 0.3rem 0 0.8rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panneau latéral desktop */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.fiche {
|
||||||
|
left: auto;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
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 {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fiche {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import GameMap from '$lib/components/GameMap.svelte';
|
||||||
|
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
|
||||||
|
import ResultPanel from '$lib/components/ResultPanel.svelte';
|
||||||
|
import TimerRing from '$lib/components/TimerRing.svelte';
|
||||||
|
import { t } from '$lib/i18n/store.svelte';
|
||||||
|
import { defi } from '$lib/stores/defi.svelte';
|
||||||
|
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const mode = $derived(data.mode);
|
||||||
|
|
||||||
|
const regionParam = $derived(page.url.searchParams.get('region'));
|
||||||
|
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 chargement = $state(true);
|
||||||
|
let erreur = $state(false);
|
||||||
|
let enCours = $state(false); // requête guess en vol
|
||||||
|
|
||||||
|
let timerRef: ReturnType<typeof TimerRing> | undefined = $state();
|
||||||
|
let joues = $state<string[]>([]); // ids déjà joués (évite les répétitions)
|
||||||
|
let bilan = $state(false); // écran final du défi
|
||||||
|
|
||||||
|
const estDefi = $derived(mode === 'defi');
|
||||||
|
const mancheCourante = $derived(defi.manches.length + 1);
|
||||||
|
const defiTermine = $derived(estDefi && defi.termine);
|
||||||
|
const paires = $derived(
|
||||||
|
defi.manches
|
||||||
|
.filter((m) => m.guess !== null)
|
||||||
|
.map((m) => ({ from: m.guess!, to: m.result.coordonnees, label: m.result.nom }))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function chargerRound(): Promise<void> {
|
||||||
|
chargement = true;
|
||||||
|
erreur = false;
|
||||||
|
place = null;
|
||||||
|
guess = null;
|
||||||
|
result = null;
|
||||||
|
indicesMontres = [];
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (regionParam) params.set('region', regionParam);
|
||||||
|
if (mode === 'monument') params.set('categorie', 'MONUMENT');
|
||||||
|
if (mode === 'lieu') params.set('categorie', 'LIEU');
|
||||||
|
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));
|
||||||
|
place = (await res.json()) as RoundPlace;
|
||||||
|
} catch {
|
||||||
|
erreur = true;
|
||||||
|
} finally {
|
||||||
|
chargement = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
joues = [...joues, place.id];
|
||||||
|
if (estDefi) {
|
||||||
|
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 {
|
||||||
|
void chargerRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
function montrerBilan(): void {
|
||||||
|
bilan = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejouerDefi(): void {
|
||||||
|
defi.reinitialiser();
|
||||||
|
joues = [];
|
||||||
|
bilan = false;
|
||||||
|
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(() => {
|
||||||
|
// Nouveau défi : on repart de zéro quand on arrive sur la première manche.
|
||||||
|
if (estDefi && defi.termine) defi.reinitialiser();
|
||||||
|
void chargerRound();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t().app.titre} — {t().modes[mode]}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="jeu">
|
||||||
|
{#if bilan}
|
||||||
|
<div class="bilan">
|
||||||
|
<h1>{t().defi.bilan}</h1>
|
||||||
|
<p class="total">
|
||||||
|
<span class="valeur">{defi.total}</span>
|
||||||
|
<span class="unite">{t().defi.scoreTotal}</span>
|
||||||
|
</p>
|
||||||
|
<div class="bilan-carte">
|
||||||
|
<GameMap interactive={false} pairs={paires} />
|
||||||
|
</div>
|
||||||
|
<ol class="recap">
|
||||||
|
{#each defi.manches as m, i (m.place.id)}
|
||||||
|
<li>
|
||||||
|
<span class="r-nom">{i + 1}. {m.result.nom}</span>
|
||||||
|
<span class="r-region">{t().regions[m.result.region]}</span>
|
||||||
|
<span class="r-score">{m.result.score} {t().resultat.points}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
<div class="bilan-actions">
|
||||||
|
<button type="button" class="btn btn-primaire" onclick={partager}>
|
||||||
|
{t().defi.partager}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondaire" onclick={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 chargement}
|
||||||
|
<div class="etat"><p>{t().jeu.charger}</p></div>
|
||||||
|
{:else if erreur}
|
||||||
|
<div class="etat">
|
||||||
|
<p>{t().jeu.erreur}</p>
|
||||||
|
<button type="button" class="btn btn-secondaire" onclick={() => void chargerRound()}>
|
||||||
|
{t().jeu.reessayer}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if place}
|
||||||
|
<div class="split" class:resultat={!!result}>
|
||||||
|
<section class="panneau-photo">
|
||||||
|
{#if result}
|
||||||
|
<div class="resultat-scroll">
|
||||||
|
<ResultPanel
|
||||||
|
{result}
|
||||||
|
creditPhoto={place.photo.credit}
|
||||||
|
labelBouton={estDefi && defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
|
||||||
|
onsuivant={estDefi && defiTermine ? montrerBilan : suivant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<PhotoPanel photo={place.photo} />
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panneau-carte">
|
||||||
|
<GameMap
|
||||||
|
bind:guess
|
||||||
|
real={result ? result.coordonnees : null}
|
||||||
|
interactive={!result}
|
||||||
|
onvalidate={() => void soumettre(false)}
|
||||||
|
/>
|
||||||
|
{#if estDefi && !defiTermine}
|
||||||
|
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
|
||||||
|
{/if}
|
||||||
|
{#if timerActif && !result}
|
||||||
|
<div class="badge-timer">
|
||||||
|
<TimerRing bind:this={timerRef} actif={!result} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !result}
|
||||||
|
<div class="barre-actions">
|
||||||
|
<div class="actions-secondaires">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-tertiaire"
|
||||||
|
onclick={montrerIndice}
|
||||||
|
disabled={indicesMontres.length >= place.indices.length}
|
||||||
|
>
|
||||||
|
{t().jeu.indice}
|
||||||
|
<span class="penalite">{t().jeu.indicePenalite}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondaire" onclick={() => void soumettre(true)}>
|
||||||
|
{t().jeu.passer}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primaire valider"
|
||||||
|
disabled={!guess || enCours}
|
||||||
|
onclick={() => void soumettre(false)}
|
||||||
|
>
|
||||||
|
{t().jeu.valider}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if indicesMontres.length > 0}
|
||||||
|
<aside class="indices" aria-live="polite">
|
||||||
|
{#each indicesMontres as texte, i (texte)}
|
||||||
|
<p class="indice"><strong>{t().jeu.indice} {i + 1}</strong> — {texte}</p>
|
||||||
|
{/each}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
{#if !guess}
|
||||||
|
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.jeu {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.etat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--oki-encre-doux);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile : photo 60 % haut, carte 40 % bas */
|
||||||
|
.panneau-photo {
|
||||||
|
flex: 0 0 58%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split.resultat .panneau-photo {
|
||||||
|
flex: 1 1 55%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panneau-carte {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultat-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--oki-blanc);
|
||||||
|
border-radius: var(--oki-radius);
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-manche {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
background: rgba(255, 253, 248, 0.94);
|
||||||
|
color: var(--oki-vert);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: var(--oki-ombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { GameMode } from '$lib/types';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
const MODES: GameMode[] = ['monument', 'lieu', 'defi'];
|
||||||
|
|
||||||
|
export const load: PageLoad = ({ params }) => {
|
||||||
|
if (!MODES.includes(params.mode as GameMode)) {
|
||||||
|
error(404, 'Mode inconnu');
|
||||||
|
}
|
||||||
|
return { mode: params.mode as GameMode };
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference no-default-lib="true"/>
|
||||||
|
/// <reference lib="esnext" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
const APP_SHELL = `jwe-shell-${version}`; // immuable, recréé à chaque build
|
||||||
|
const PHOTOS = 'jwe-photos'; // stale-while-revalidate
|
||||||
|
|
||||||
|
const ASSETS = [...build, ...files];
|
||||||
|
|
||||||
|
sw.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(caches.open(APP_SHELL).then((cache) => cache.addAll(ASSETS)));
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== APP_SHELL && key !== PHOTOS)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPhoto(url: URL): boolean {
|
||||||
|
return (
|
||||||
|
url.hostname === 'upload.wikimedia.org' ||
|
||||||
|
(url.hostname === 'commons.wikimedia.org' && url.pathname.startsWith('/wiki/Special:FilePath'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
// App shell : cache d'abord
|
||||||
|
if (ASSETS.includes(url.pathname)) {
|
||||||
|
event.respondWith(caches.match(event.request).then((r) => r ?? fetch(event.request)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos Wikimedia : stale-while-revalidate
|
||||||
|
if (isPhoto(url)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(PHOTOS).then(async (cache) => {
|
||||||
|
const cached = await cache.match(event.request);
|
||||||
|
const frais = fetch(event.request)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) void cache.put(event.request, res.clone());
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => cached as Response);
|
||||||
|
return cached ?? frais;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#0b3d2e"/>
|
||||||
|
<circle cx="256" cy="256" r="130" fill="none" stroke="#f5efe2" stroke-width="22"/>
|
||||||
|
<path d="M256 148 L312 330 L256 292 L200 330 Z" fill="#e4572e"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#0b3d2e"/>
|
||||||
|
<circle cx="256" cy="256" r="150" fill="none" stroke="#f5efe2" stroke-width="26"/>
|
||||||
|
<path d="M256 130 L322 344 L256 300 L190 344 Z" fill="#e4572e"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "JWE — Jeu de géolocalisation OKI",
|
||||||
|
"short_name": "JWE",
|
||||||
|
"description": "Devine la commune à partir d'une photo — Guadeloupe, Martinique, Guyane, La Réunion.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f5efe2",
|
||||||
|
"theme_color": "#0b3d2e",
|
||||||
|
"lang": "fr",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user