From 70c30d32927f2f8844095840067b5ad4c821f00a Mon Sep 17 00:00:00 2001 From: Clement <159007607+csanchez-jetdev@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:34:50 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20recherche=20invisible=20sur=20mobile?= =?UTF-8?q?,=20et=20remont=C3=A9e=20au-dessus=20de=20la=20carte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le repli du formulaire en résumé se déclenchait sur tout changement du résultat d'itinéraire. `useItinerary` étant `server: false`, celui-ci passe de `undefined` à `null` au montage : sur écran étroit le formulaire se repliait aussitôt, sans résumé à afficher puisqu'aucune recherche n'avait eu lieu — plus rien n'était visible ni cliquable. Le repli exige désormais un résultat, et son état dérive de la présence d'un résumé. Le corps de l'application passe en grille : recherche, carte puis résultats sur écran étroit, colonne recherche + résultats à gauche et carte à droite sur desktop. La carte mobile descend de 38vh à 32dvh. Lien vers le dépôt dans l'en-tête. --- app/components/GithubLink.vue | 28 +++++++++++ app/pages/app.vue | 89 ++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 34 deletions(-) create mode 100644 app/components/GithubLink.vue 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/pages/app.vue b/app/pages/app.vue index ea12357..454258f 100644 --- a/app/pages/app.vue +++ b/app/pages/app.vue @@ -52,8 +52,11 @@ onMounted(() => { isNarrow.value = mq.matches mq.addEventListener('change', (e) => (isNarrow.value = e.matches)) }) -watch([result, () => itinerary.route.value], () => { - if (isNarrow.value) formOpen.value = false +// Ne replier que sur une recherche qui a effectivement abouti : `useItinerary` étant +// `server: false`, son résultat passe de `undefined` à `null` au montage, et ce seul +// changement suffisait à replier un formulaire qui n'avait encore rien à résumer. +watch([result, () => itinerary.route.value], ([found, foundRoute]) => { + if (isNarrow.value && (found || foundRoute)) formOpen.value = false }) function onSearch(params: Parameters[0]) { search(params) @@ -75,6 +78,12 @@ const summary = computed(() => { return `${where} · ${when}` }) +/** + * État replié, dérivé plutôt que déclaré : il exige un résumé à afficher, ce qui garantit + * qu'un des deux affichages est toujours visible et jamais une colonne de recherche vide. + */ +const collapsed = computed(() => isNarrow.value && !formOpen.value && Boolean(summary.value)) + async function onShowReturns(destLabel: string) { if (!result.value) return await loadReturns(destLabel, result.value.origin.label, result.value.date) @@ -119,12 +128,50 @@ useHead({ Trainquillou Trainquillou + - -
- -
+ +
+ +
+ + + + +
+ + +
- -
- - From c242cc657db574fcba1682ed854afd7168606380 Mon Sep 17 00:00:00 2001 From: Clement <159007607+csanchez-jetdev@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:43:52 +0200 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20serveur=20de=20d=C3=A9veloppement?= =?UTF-8?q?=20sur=20le=20port=203001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3000 est occupé par d'autres services sur la machine de développement. Sans port fixe, Nuxt en choisit un libre au hasard et les URL changent d'une session à l'autre. La production n'est pas concernée : son port vient de infra/compose.yml. --- README.md | 4 ++-- nuxt.config.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) 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/nuxt.config.ts b/nuxt.config.ts index 6ae31d8..ab9fbe7 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -32,6 +32,13 @@ export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: true }, css: ['~/assets/css/main.css'], + + // 3000 est le port par défaut de trop de choses — dont Langfuse, qui tourne en + // conteneur sur la machine de développement. Sans port fixe, Nuxt en choisit un + // libre au hasard et les URL de test changent d'une session à l'autre. + // Surchargeable par `PORT` ou `pnpm dev --port`. Ne concerne pas la production, + // dont le port est fixé par infra/compose.yml. + devServer: { port: 3001 }, vite: { plugins: [tailwindcss()], optimizeDeps: { From 1d585b23bcc509732772c047da306fd9f018c5a6 Mon Sep 17 00:00:00 2001 From: Clement <159007607+csanchez-jetdev@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:23:54 +0200 Subject: [PATCH 5/6] feat: bascule carte / liste sur mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carte et liste occupent la même cellule de grille sur écran étroit, une bascule flottante décide laquelle est devant. Taper une destination dans la liste passe sur la carte, où sa fiche s'ancre. La carte reste dimensionnée sous la liste plutôt que masquée : son cadrage se calcule sur la taille du conteneur. Le cadrage remplace ses 400 px de marge à gauche par une fraction du conteneur. Sur un écran de 393 px, cette marge donnait une largeur utile négative et sortait la moitié des marqueurs du cadre. Le suivi de taille de la carte passe de MapLibre à un ResizeObserver local : le sien écarte sa première notification, qui absorbait ici le repli du formulaire et figeait le canevas aux deux tiers de sa ligne. --- app/components/MapView.client.vue | 46 ++++++++- app/components/ResultsRail.vue | 7 +- app/components/RoutePanel.vue | 4 +- app/pages/app.vue | 158 +++++++++++++++++++++++++----- 4 files changed, 186 insertions(+), 29 deletions(-) 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
-
    + échelle. La marge négative annule le padding de la colonne. + `pb-14` sur mobile : de quoi faire défiler la dernière destination au-dessus de + la bascule carte / liste, qui flotte par-dessus le bas de la liste. Du défilement + en plus, pas de la hauteur utile en moins. --> +
      -
        + +
        • (null) @@ -47,10 +47,27 @@ const returnsByDest = computed(() => { */ const isNarrow = ref(false) const formOpen = ref(true) + +/** + * Sur un écran étroit, carte et liste ne tiennent pas ensemble : la carte réduite au + * tiers de la hauteur ne séparait pas les marqueurs franciliens, et les trois + * destinations qui restaient visibles ne faisaient pas une liste. On en montre donc + * une seule à la fois, au choix. + */ +const mobileView = ref<'map' | 'list'>('list') +const MOBILE_VIEWS = [ + { key: 'map', label: 'Carte', icon: 'M12 21c4-4.6 6-7.8 6-10.5a6 6 0 1 0-12 0C6 13.2 8 16.4 12 21Zm0-9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z' }, + { key: 'list', label: 'Liste', icon: 'M4 6h16M4 12h16M4 18h16' }, +] as const + onMounted(() => { const mq = window.matchMedia('(max-width: 767px)') isNarrow.value = mq.matches mq.addEventListener('change', (e) => (isNarrow.value = e.matches)) + // Sans recherche en cours, la liste n'a qu'une phrase à afficher : la carte fait un + // meilleur écran d'accueil. Avec une recherche — lien partagé, retour navigateur — + // les résultats arrivent, autant ouvrir là où ils vont s'afficher. + mobileView.value = hasQuery.value ? 'list' : 'map' }) // Ne replier que sur une recherche qui a effectivement abouti : `useItinerary` étant // `server: false`, son résultat passe de `undefined` à `null` au montage, et ce seul @@ -60,7 +77,36 @@ watch([result, () => itinerary.route.value], ([found, foundRoute]) => { }) function onSearch(params: Parameters[0]) { search(params) - if (isNarrow.value) formOpen.value = false + if (!isNarrow.value) return + formOpen.value = false + // Vers la liste : c'est elle qui porte le squelette de chargement, le message d'erreur + // et son bouton de reprise, le décompte, le tri et les filtres. Rester sur la carte + // laisserait la recherche sans retour visible pendant qu'elle tourne. + mobileView.value = 'list' +} + +/** Sur desktop les deux affichages cohabitent ; sur écran étroit la bascule tranche. */ +const showList = computed(() => !isNarrow.value || mobileView.value === 'list') +/** + * Carte recouverte par la liste : elle reste dimensionnée mais sort du parcours clavier + * et de l'arbre d'accessibilité, sinon ses marqueurs — qui sont des boutons — restent + * atteignables derrière la liste qui les cache. + */ +const mapCovered = computed(() => isNarrow.value && showList.value) + +/** + * Ouvrir une fiche depuis la liste, sur écran étroit, suppose de passer sur la carte : + * c'est là qu'elle s'ancre, et un appui sans effet visible se lit comme une panne. + * Et toujours sélectionner, jamais désélectionner — le second appui d'une bascule n'a + * pas de sens quand on n'a pas vu le résultat du premier. + */ +function onSelectDestination(label: string) { + if (isNarrow.value && mobileView.value === 'list') { + selectedDestination.value = label + mobileView.value = 'map' + return + } + selectedDestination.value = selectedDestination.value === label ? null : label } /** Résumé de la recherche en cours, affiché à la place du formulaire replié. */ @@ -132,19 +178,33 @@ useHead({ -
          +
          -
          + la carte et la liste, ses voisines immédiates sur mobile. --> +
          - -
          + +
          - -
          From e44d7fa90f3eed080c59cfa7c11c752bd02e3887 Mon Sep 17 00:00:00 2001 From: Clement <159007607+csanchez-jetdev@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:50:27 +0200 Subject: [PATCH 6/6] feat: recherche en barre horizontale sur desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La recherche traverse le haut de l'écran au lieu d'occuper le tiers supérieur de la colonne de gauche. À 1280×800, elle passe de 309 px de haut à 108, et la liste de 4,9 à 8,1 destinations visibles. Les rangées de dates deviennent `display: contents` sur la barre : leurs cellules s'alignent dans le cadre sans dupliquer le balisage. Les deux textes d'aide passent sur une seule ligne, aucun n'est supprimé — le premier est le seul endroit où le mode de recherche est énoncé. `flex-wrap` plutôt qu'un palier de largeur supplémentaire : la barre se plie en deux rangées sous 1 000 px. --- app/components/SearchBar.vue | 116 +++++++++++++++++++++++------------ app/pages/app.vue | 14 ++--- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/app/components/SearchBar.vue b/app/components/SearchBar.vue index 0d810e9..1c62bc0 100644 --- a/app/components/SearchBar.vue +++ b/app/components/SearchBar.vue @@ -181,32 +181,53 @@ function submit() {