feat: refonte layout viewport + passe motion + finitions SEO/sécurité
- Layout app-style 100dvh : zéro scroll desktop (accueil, jeu, bilan, explorer), zéro superposition mobile (sauts sortis de la carte, badges consolidés en une puce, barre d'actions opaque z-indexée, indices en panneau interne, sheet explorer dégagé) - CSS jeu mutualisé (jeu.css) + partager() factorisé (lib/partage.ts) - Motion gwoka : reveals syncopés, révélation designée (ligne de distance animée, score en count-up, marqueur qui tombe), transitions d'état, bilan en cascade - SEO : Seo.svelte (canonical, OG, Twitter), robots.txt, sitemap.xml - Sécurité : hooks.server.ts (CSP, nosniff, DENY, Referrer-Policy, Permissions-Policy) - Dette : 4 clés i18n mortes, zip debug ignoré - Vérifié puppeteer : mobile 390x844 (0 overlap, 0 scroll), desktop 1440x900 (0 scroll même +2 indices), révélation complète, zéro erreur console/CSP
This commit is contained in:
@@ -125,6 +125,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/store.svelte';
|
||||
import { reveal } from '$lib/motion/reveal';
|
||||
import { syncopatedDelay } from '$lib/motion/tokens';
|
||||
|
||||
interface Props {
|
||||
/** Région sélectionnée (liaison bidirectionnelle) — ignorée si `selectionnable` est faux. */
|
||||
@@ -133,13 +135,19 @@
|
||||
selectionnable?: boolean;
|
||||
marqueurs?: MarqueurCarte[];
|
||||
attribution?: string;
|
||||
/**
|
||||
* Base d'index de la cascade syncopée d'entrée des 4 insets (accueil :
|
||||
* 5, après les 5 cartes de mode). null (défaut) = pas de cascade.
|
||||
*/
|
||||
cascadeBase?: number | null;
|
||||
}
|
||||
|
||||
let {
|
||||
selection = $bindable<SelectionCarte>(null),
|
||||
selectionnable = true,
|
||||
marqueurs = [],
|
||||
attribution = '© contributeurs OpenStreetMap / données publiques'
|
||||
attribution = '© contributeurs OpenStreetMap / données publiques',
|
||||
cascadeBase = null
|
||||
}: Props = $props();
|
||||
|
||||
let survol = $state<Region | null>(null);
|
||||
@@ -185,11 +193,14 @@
|
||||
aria-hidden={selectionnable ? 'true' : undefined}
|
||||
aria-label={selectionnable ? undefined : t().carte.carteRegionsLabel}
|
||||
>
|
||||
{#each INSETS as ins (ins.region)}
|
||||
{#each INSETS as ins, i (ins.region)}
|
||||
<g
|
||||
class="region"
|
||||
class:survolee={selectionnable && survol === ins.region}
|
||||
class:choisie={selectionnable && selection === ins.region}
|
||||
data-reveal={cascadeBase === null ? undefined : ''}
|
||||
style:--d={cascadeBase === null ? undefined : `${syncopatedDelay(cascadeBase + i)}ms`}
|
||||
use:reveal={cascadeBase !== null}
|
||||
>
|
||||
{#each ins.chemins as d (d)}
|
||||
<path {d} />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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 { dureeMs, easeKa, syncopatedDelay } from '$lib/motion/tokens';
|
||||
import type { Coordonnees, Region } from '$lib/types';
|
||||
import type { Map as MLMap, Marker, StyleSpecification } from 'maplibre-gl';
|
||||
|
||||
@@ -51,6 +52,8 @@
|
||||
let realMarker: Marker | null = null;
|
||||
let explorerMarkers: Marker[] = [];
|
||||
let observateurTaille: ResizeObserver | null = null;
|
||||
let rafLigne: number | null = null; // tracé progressif de la ligne de distance
|
||||
let pulseAttendu = false; // pulse du marqueur au prochain positionnement (clic / dragend)
|
||||
|
||||
const SRC_LIGNE = 'jwe-ligne';
|
||||
const SRC_PAIRES = 'jwe-paires';
|
||||
@@ -94,6 +97,13 @@
|
||||
};
|
||||
}
|
||||
|
||||
function annulerTraceLigne(): void {
|
||||
if (rafLigne !== null) {
|
||||
cancelAnimationFrame(rafLigne);
|
||||
rafLigne = null;
|
||||
}
|
||||
}
|
||||
|
||||
function dessinerRevelation(): void {
|
||||
if (!map || !ml || !real) return;
|
||||
ensureSource(SRC_LIGNE);
|
||||
@@ -119,25 +129,50 @@
|
||||
paint: { 'text-color': '#0b3d2e', 'text-halo-color': '#fffdf8', 'text-halo-width': 2 }
|
||||
});
|
||||
}
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
if (guess) {
|
||||
features.push(ligneFeature(guess, real, null));
|
||||
if (distanceKm !== null) {
|
||||
// Étiquette de distance au milieu de la ligne guess ↔ réel
|
||||
const positionReelle = real;
|
||||
/** Features de la ligne guess ↔ réel à la progression donnée (1 = complète). */
|
||||
function featuresLigne(progression: number): GeoJSON.Feature[] {
|
||||
if (!guess) return [];
|
||||
const tete = {
|
||||
lat: guess.lat + (positionReelle.lat - guess.lat) * progression,
|
||||
lon: guess.lon + (positionReelle.lon - guess.lon) * progression
|
||||
};
|
||||
const features: GeoJSON.Feature[] = [ligneFeature(guess, tete, null)];
|
||||
if (progression >= 1 && distanceKm !== null) {
|
||||
// Étiquette de distance au milieu de la ligne, posée à la fin du tracé
|
||||
features.push(
|
||||
pointFeature(
|
||||
{ lat: (guess.lat + real.lat) / 2, lon: (guess.lon + real.lon) / 2 },
|
||||
{ lat: (guess.lat + positionReelle.lat) / 2, lon: (guess.lon + positionReelle.lon) / 2 },
|
||||
{ etiquette: `${distanceKm} km` }
|
||||
)
|
||||
);
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
annulerTraceLigne();
|
||||
const dureeTrace = dureeMs('--dur-phrase');
|
||||
if (guess && !reducedMotion && dureeTrace > 0) {
|
||||
// La ligne de distance se dessine depuis le guess vers le lieu réel :
|
||||
// croissance progressive du GeoJSON via rAF (jamais d'état réactif
|
||||
// par frame), easing --ease-ka, durée lue du token CSS.
|
||||
const t0 = performance.now();
|
||||
const tick = (t: number): void => {
|
||||
const p = Math.min(1, (t - t0) / dureeTrace);
|
||||
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: featuresLigne(easeKa(p)) });
|
||||
rafLigne = p < 1 ? requestAnimationFrame(tick) : null;
|
||||
};
|
||||
rafLigne = requestAnimationFrame(tick);
|
||||
} else {
|
||||
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: featuresLigne(1) });
|
||||
}
|
||||
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features });
|
||||
|
||||
// Marqueur vert = position réelle ; le marqueur corail du joueur RESTE
|
||||
// à sa position : la comparaison visuelle guess ↔ réel est le cœur du jeu.
|
||||
realMarker?.remove();
|
||||
realMarker = new ml.Marker({ color: '#0b3d2e' }).setLngLat([real.lon, real.lat]).addTo(map);
|
||||
// Le marqueur « tombe » avec un petit rebond (transform only, keyframes CSS).
|
||||
if (!reducedMotion) realMarker.getElement().classList.add('tombe');
|
||||
|
||||
if (guess) {
|
||||
const bounds = new ml.LngLatBounds(
|
||||
@@ -156,6 +191,7 @@
|
||||
}
|
||||
|
||||
function nettoyerRevelation(): void {
|
||||
annulerTraceLigne();
|
||||
realMarker?.remove();
|
||||
realMarker = null;
|
||||
setGeoJSON(SRC_LIGNE, { type: 'FeatureCollection', features: [] });
|
||||
@@ -171,6 +207,7 @@
|
||||
guessMarker.on('dragend', () => {
|
||||
const ll = guessMarker?.getLngLat();
|
||||
if (ll) {
|
||||
pulseAttendu = true; // le déplacement au drag est aussi un placement
|
||||
guess = { lat: ll.lat, lon: ll.lng };
|
||||
onguess?.(guess);
|
||||
}
|
||||
@@ -179,6 +216,17 @@
|
||||
guessMarker.setLngLat([guess.lon, guess.lat]);
|
||||
}
|
||||
guessMarker.setDraggable(interactive && !real);
|
||||
if (pulseAttendu) {
|
||||
pulseAttendu = false;
|
||||
if (!reducedMotion) {
|
||||
// Feedback de placement : pulse léger (redémarrage forcé si le
|
||||
// marqueur bouge à nouveau pendant l'animation).
|
||||
const el = guessMarker.getElement();
|
||||
el.classList.remove('pulse');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('pulse');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
guessMarker?.remove();
|
||||
guessMarker = null;
|
||||
@@ -230,11 +278,15 @@
|
||||
if (!map || !ml) return;
|
||||
for (const m of explorerMarkers) m.remove();
|
||||
explorerMarkers = [];
|
||||
for (const info of markers) {
|
||||
for (const [i, info] of markers.entries()) {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'marqueur-explorer';
|
||||
el.setAttribute('aria-label', info.label);
|
||||
// Cascade syncopée au (re)filtrage : rythme 3+3+2 sur le premier
|
||||
// cycle, plafonné à 960 ms au-delà (le catalogue peut être long).
|
||||
// Sans effet en reduced-motion : l'animation est sous gate média.
|
||||
el.style.animationDelay = `${Math.min(syncopatedDelay(i), 960)}ms`;
|
||||
const mk = new ml.Marker({ element: el, color: '#175e70' })
|
||||
.setLngLat([info.lon, info.lat])
|
||||
.addTo(map!);
|
||||
@@ -313,6 +365,7 @@
|
||||
map.addControl(new mod.NavigationControl({ showCompass: false }), 'top-left');
|
||||
map.on('click', (e) => {
|
||||
if (!interactive || real) return;
|
||||
pulseAttendu = true; // feedback au placement du marqueur
|
||||
guess = { lat: e.lngLat.lat, lon: e.lngLat.lng };
|
||||
onguess?.(guess);
|
||||
});
|
||||
@@ -329,6 +382,7 @@
|
||||
})();
|
||||
return () => {
|
||||
detruit = true;
|
||||
annulerTraceLigne();
|
||||
observateurTaille?.disconnect();
|
||||
observateurTaille = null;
|
||||
map?.remove();
|
||||
@@ -392,33 +446,39 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.carte {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--oki-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile : les sauts sortent de la carte — barre horizontale SOUS la carte,
|
||||
dimensionnée pour que les 4 régions tiennent sur 360px+ (scroll en repli),
|
||||
jamais recouverte par l'attribution ni un overlay. */
|
||||
.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;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.3rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.1rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.saut {
|
||||
flex: 0 0 auto;
|
||||
min-height: 48px; /* cible tactile */
|
||||
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;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -429,6 +489,22 @@
|
||||
background: var(--oki-vert-doux);
|
||||
}
|
||||
|
||||
/* Desktop : overlay bas-gauche, dégagé de l'attribution compacte (bas-droite). */
|
||||
@media (min-width: 768px) {
|
||||
.sauts {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
left: 0.6rem;
|
||||
max-width: calc(100% - 5rem);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.saut {
|
||||
min-height: 0;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
} }
|
||||
|
||||
:global(.marqueur-explorer) {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -443,4 +519,61 @@
|
||||
:global(.marqueur-explorer:focus-visible) {
|
||||
background: var(--oki-vert-clair);
|
||||
}
|
||||
|
||||
/* — Motion des marqueurs : transform/opacity uniquement, gate no-preference
|
||||
(les classes tombe/pulse ne sont posées qu'hors reduced-motion, et les
|
||||
délais inline de la cascade explorer sont sans effet sans animation). — */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Cascade syncopée d'entrée des marqueurs du catalogue explorer */
|
||||
:global(.marqueur-explorer) {
|
||||
animation: marqueur-pop var(--dur-mesure) var(--ease-ka) backwards;
|
||||
}
|
||||
|
||||
/* Le marqueur du lieu réel « tombe » avec un petit rebond */
|
||||
:global(.maplibregl-marker.tombe > svg) {
|
||||
transform-origin: bottom center;
|
||||
animation: marqueur-tombe var(--dur-mesure) var(--ease-ka);
|
||||
}
|
||||
|
||||
/* Pulse léger au placement du marqueur de guess */
|
||||
:global(.maplibregl-marker.pulse > svg) {
|
||||
transform-origin: bottom center;
|
||||
animation: marqueur-pulse var(--dur-mesure) var(--ease-ka);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marqueur-pop {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marqueur-tombe {
|
||||
0% {
|
||||
transform: translateY(-34px) scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
55% {
|
||||
transform: translateY(3px) scale(1.03);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marqueur-pulse {
|
||||
0% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,5 +59,6 @@
|
||||
font-size: 0.72rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(transparent, rgba(11, 61, 46, 0.85));
|
||||
pointer-events: none; /* décoratif : ne bloque jamais les clics */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import WikiFiche from './WikiFiche.svelte';
|
||||
import { t } from '$lib/i18n/store.svelte';
|
||||
import { compte } from '$lib/motion/countup';
|
||||
import type { GuessResult } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +18,11 @@
|
||||
<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>
|
||||
<!-- Count-up animé (aria-hidden) doublé de la valeur finale pour les
|
||||
lecteurs d'écran : la section est aria-live, l'animation rAF
|
||||
écrirait sinon une valeur par frame. -->
|
||||
<span class="valeur" aria-hidden="true" use:compte={result.score}>{result.score}</span>
|
||||
<span class="sr-only">{result.score}</span>
|
||||
<span class="unite">{t().resultat.points}</span>
|
||||
</p>
|
||||
{#if result.distanceKm !== null}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { SITE_NAME, SITE_URL } from '$lib/site';
|
||||
|
||||
interface Props {
|
||||
/** Titre de la page (sans le nom du site) — « JWE — … » est composé ici. */
|
||||
title: string;
|
||||
description: string;
|
||||
/** Chemin absolu de la route (ex. `/explorer`) — canonical + og:url. */
|
||||
path: string;
|
||||
/** URL absolue d'une image og (aucune émise si absente — pas d'image inventée). */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
let { title, description, path, image }: Props = $props();
|
||||
|
||||
const titreComplet = $derived(`${SITE_NAME} — ${title}`);
|
||||
const urlCanonique = $derived(`${SITE_URL}${path}`);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{titreComplet}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={urlCanonique} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={urlCanonique} />
|
||||
<meta property="og:title" content={titreComplet} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
{#if image}
|
||||
<meta property="og:image" content={image} />
|
||||
{/if}
|
||||
|
||||
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||
<meta name="twitter:title" content={titreComplet} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{#if image}
|
||||
<meta name="twitter:image" content={image} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
Reference in New Issue
Block a user