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,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>
|
||||
Reference in New Issue
Block a user