422 lines
11 KiB
Svelte
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>
|