diff --git a/README.md b/README.md index cb39b67..85ffdd4 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Prérequis : **Node 20+** et **pnpm**. git clone https://github.com/csanchez-jetdev/trainquillou.git cd trainquillou pnpm install -pnpm dev # http://localhost:3000 +pnpm dev # http://localhost:3001 ``` Aucune clé d'API, aucun compte, aucun fichier `.env` : l'open data SNCF est ouvert et le @@ -170,7 +170,7 @@ format stable. Elles sont utilisables telles quelles si vous auto-hébergez. exigent `dateTo`. ```bash -curl 'http://localhost:3000/api/search?origin=LYON%20(intramuros)&date=2026-08-14' +curl 'http://localhost:3001/api/search?origin=LYON%20(intramuros)&date=2026-08-14' ``` ## Architecture diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 0ffb543..6cb12d9 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -15,6 +15,19 @@ /* Fond crème */ --color-cream: #fafaf7; + /* + * Couleurs des deux revendeurs, relevées sur leurs propres pages (styles calculés, + * pas une palette de seconde main) : reconnaître la destination d'un lien sortant + * avant de cliquer vaut mieux que deux liens soulignés identiques. + * + * `trainline` est le vert foncé de leurs propres boutons, pas le vert clair de leur + * logo (#00a88f) : celui-ci ne donne que 3:1 avec du texte blanc, sous le seuil AA. + */ + --color-sncf: #0c131f; + --color-sncf-hover: #242a3e; + --color-trainline: #00705f; + --color-trainline-hover: #00a88f; + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; } diff --git a/app/components/DestinationPopover.vue b/app/components/DestinationPopover.vue index 37ffa73..b899750 100644 --- a/app/components/DestinationPopover.vue +++ b/app/components/DestinationPopover.vue @@ -190,25 +190,33 @@ function formatDate(iso: string): string { - -
- - Réserver sur SNCF Connect - - - Trainline - + +
+

Réserver ce trajet

+
diff --git a/app/components/GithubLink.vue b/app/components/GithubLink.vue new file mode 100644 index 0000000..0c7a59e --- /dev/null +++ b/app/components/GithubLink.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/components/HeroSearch.vue b/app/components/HeroSearch.vue new file mode 100644 index 0000000..2aed930 --- /dev/null +++ b/app/components/HeroSearch.vue @@ -0,0 +1,71 @@ + + + diff --git a/app/components/MapView.client.vue b/app/components/MapView.client.vue index b8a0d9b..f49695f 100644 --- a/app/components/MapView.client.vue +++ b/app/components/MapView.client.vue @@ -48,6 +48,7 @@ const TINTS: Array<{ id: string; prop: string; color: string }> = [ const instance = getCurrentInstance() let map: maplibregl.Map | null = null +let sizeObserver: ResizeObserver | null = null /** * Le style est chargé et les couches peuvent être ajoutées. On ne peut pas se fier à * `map.loaded()` : il renvoie `false` pendant une animation de caméra, et un @@ -160,7 +161,28 @@ onMounted(async () => { // L'attribution (OpenFreeMap, OpenMapTiles, OpenStreetMap) vient du TileJSON de // la source : la déclarer ici la ferait apparaître en double. attributionControl: { compact: true }, + // Le suivi de taille est fait ici, voir plus bas. + trackResize: false, }) + + /** + * Suivi de la taille du conteneur, à la place de celui de MapLibre. Le sien écarte sa + * première notification — celle que `ResizeObserver` émet toujours à l'abonnement — + * pour ne pas refaire le calcul du constructeur. Mais si la mise en page bouge dans la + * même frame que la création de la carte, les deux tailles se fondent en une seule + * notification, celle-ci est écartée, et le canevas reste figé à sa taille de départ + * pour de bon. C'est le cas ici dès qu'une recherche aboutit : elle replie le + * formulaire, la ligne de la carte grandit d'autant, et la carte s'arrêtait aux deux + * tiers de sa ligne sur mobile. + */ + let lastSize = '' + sizeObserver = new ResizeObserver(() => { + const size = `${container.clientWidth}×${container.clientHeight}` + if (size === lastSize) return + lastSize = size + map?.resize() + }) + sizeObserver.observe(container) // Render initial state once map tiles are ready map.once('load', () => { styleReady = true @@ -177,6 +199,8 @@ onMounted(async () => { }) onBeforeUnmount(() => { + sizeObserver?.disconnect() + sizeObserver = null map?.remove() map = null }) @@ -192,6 +216,24 @@ function removeLayerSource(id: string) { if (map.getSource(id)) map.removeSource(id) } +/** + * Marge du cadrage, en pixels. Elle était fixée à 400 px à gauche, du temps où la carte + * passait en pleine largeur sous la colonne de recherche ; celle-ci occupe désormais sa + * propre colonne de grille et cette marge ne faisait plus que tasser les points contre le + * bord droit. Sur un téléphone elle était fatale : 400 px de marge dans 393 px de large + * donnent une largeur utile négative, et le cadrage sortait la moitié des marqueurs du + * cadre — dont la gare de départ. + * + * Donc une fraction du conteneur, plafonnée : une marge doit aérer le cadrage, pas le + * manger, et elle ne peut jamais dépasser ce qu'elle borde. + */ +function fitPadding(): maplibregl.PaddingOptions { + const el = map?.getContainer() + const x = Math.min(60, Math.round((el?.clientWidth ?? 480) / 8)) + const y = Math.min(60, Math.round((el?.clientHeight ?? 480) / 8)) + return { top: y, bottom: y, left: x, right: x } +} + /** Crée un élément DOM de marqueur (pastille colorée). */ function dot(color: string, size: number): HTMLDivElement { const el = document.createElement('div') @@ -245,7 +287,7 @@ function renderRoute(route: RouteResult, selected: number) { if (pts.length > 1) { const bounds = new maplibregl.LngLatBounds() pts.forEach((p) => bounds.extend([p[1], p[0]])) - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 400, right: 60 }, maxZoom: 8, duration: 800 }) + map.fitBounds(bounds, { padding: fitPadding(), maxZoom: 8, duration: 800 }) } } @@ -328,7 +370,7 @@ function render(result: SearchResult | null | undefined) { if (pts.length > 1) { const b = new maplibregl.LngLatBounds() pts.forEach((p) => b.extend([p[1], p[0]])) - map.fitBounds(b, { padding: { top: 60, bottom: 60, left: 400, right: 60 }, maxZoom: 8, duration: 800 }) + map.fitBounds(b, { padding: fitPadding(), maxZoom: 8, duration: 800 }) } } diff --git a/app/components/ResultsRail.vue b/app/components/ResultsRail.vue index 4dc187c..8fa8a70 100644 --- a/app/components/ResultsRail.vue +++ b/app/components/ResultsRail.vue @@ -201,8 +201,11 @@ const CHIP_OFF = 'border-slate-200 bg-white text-rail-soft hover:border-slate-30 -