Files
JWE/app/src/lib/components/GameMap.svelte
T

422 lines
11 KiB
Svelte

<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>