Import initial : code en production sur Hetzner
This commit is contained in:
commit
cf6ee64e31
43 changed files with 14404 additions and 0 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
.env
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
99
.forgejo/workflows/ci.yml
Normal file
99
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: deploiement-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:24-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Installer les dependances
|
||||
run: npm ci
|
||||
|
||||
- name: Tests unitaires
|
||||
run: npm run test:unit -- --run
|
||||
|
||||
- name: Installer Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Tests end-to-end
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
NUXT_ORS_API_KEY: test
|
||||
|
||||
deploiement:
|
||||
needs: tests
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:24-bookworm
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env:
|
||||
AWS_DEFAULT_REGION: eu-north-1
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
ECR: ${{ secrets.ECR_REPOSITORY }}
|
||||
CLUSTER: lecheminvert
|
||||
SERVICE: lecheminvert
|
||||
FAMILLE: lecheminvert
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Installer docker, aws et jq
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker.io awscli jq
|
||||
|
||||
- name: Determiner l etiquette de l image
|
||||
run: echo "TAG=$(echo "$GITHUB_SHA" | cut -c1-12)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: S authentifier aupres d ECR
|
||||
run: |
|
||||
aws ecr get-login-password | docker login --username AWS --password-stdin "${ECR%%/*}"
|
||||
|
||||
- name: Construire et pousser l image
|
||||
run: |
|
||||
docker build --platform linux/arm64 -t "$ECR:$TAG" .
|
||||
docker push "$ECR:$TAG"
|
||||
|
||||
- name: Enregistrer la definition de tache
|
||||
run: |
|
||||
aws ecs describe-task-definition --task-definition "$FAMILLE" \
|
||||
--query taskDefinition > actuelle.json
|
||||
|
||||
jq --arg image "$ECR:$TAG" '
|
||||
del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
|
||||
.compatibilities, .registeredAt, .registeredBy, .deregisteredAt)
|
||||
| .containerDefinitions[0].image = $image
|
||||
' actuelle.json > nouvelle.json
|
||||
|
||||
REVISION=$(aws ecs register-task-definition \
|
||||
--cli-input-json file://nouvelle.json \
|
||||
--query 'taskDefinition.revision' --output text)
|
||||
echo "Revision enregistree : $REVISION"
|
||||
|
||||
- name: Redeployer le service
|
||||
run: |
|
||||
aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" \
|
||||
--task-definition "$FAMILLE" --force-new-deployment \
|
||||
--query 'service.deployments[0].status' --output text
|
||||
|
||||
- name: Attendre la stabilisation
|
||||
run: aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE"
|
||||
|
||||
- name: Verifier le service en ligne
|
||||
run: |
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' https://lecheminvert.tech/api/health)
|
||||
echo "GET /api/health -> $CODE"
|
||||
test "$CODE" = "200"
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
test-results
|
||||
playwright-report
|
||||
1
.nuxtrc
Normal file
1
.nuxtrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
setups.@nuxt/test-utils="4.1.0"
|
||||
3
Caddyfile
Normal file
3
Caddyfile
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
lecheminvert.tech, www.lecheminvert.tech {
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Etape 1 : on construit l'application avec toutes les dependances.
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Etape 2 : l'image finale ne garde que le resultat du build.
|
||||
# Ni sources, ni node_modules de developpement : environ 150 Mo au lieu de 800.
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NITRO_PORT=3000
|
||||
ENV NITRO_HOST=0.0.0.0
|
||||
|
||||
# On ne tourne pas en root : si l'application est compromise,
|
||||
# l'attaquant n'a pas les pleins pouvoirs dans le conteneur.
|
||||
RUN addgroup -S nuxt && adduser -S nuxt -G nuxt
|
||||
COPY --from=builder --chown=nuxt:nuxt /app/.output ./.output
|
||||
USER nuxt
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
476
app/app.vue
Normal file
476
app/app.vue
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
<script setup lang="ts">
|
||||
import type { Coord, Place, Segment } from '#shared/types'
|
||||
import { buildBookingLink } from '#shared/booking'
|
||||
|
||||
interface Option {
|
||||
mode: 'electric_car' | 'bike' | 'train' | 'bus'
|
||||
distanceKm?: number
|
||||
durationMin?: number
|
||||
co2Grams?: number
|
||||
transfers?: number
|
||||
segments?: Segment[]
|
||||
geometry?: Coord[]
|
||||
unavailable?: boolean
|
||||
reason?: string
|
||||
/** true si l estimation a ete generee par nos soins (Transitous ne couvrait pas). */
|
||||
estimated?: boolean
|
||||
}
|
||||
|
||||
const from = ref<Place | null>(null)
|
||||
const to = ref<Place | null>(null)
|
||||
const canCompare = computed(() => Boolean(from.value && to.value))
|
||||
|
||||
const options = ref<Option[] | null>(null)
|
||||
const pending = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const searched = ref<{ from: Place, to: Place } | null>(null)
|
||||
const selected = ref<Option['mode'] | null>(null)
|
||||
|
||||
/** Formulaire de saisie visible ou non. Se referme apres un compare reussi
|
||||
* sur mobile pour liberer de l espace ; toujours ouvert en desktop via CSS. */
|
||||
const formOpen = ref(true)
|
||||
|
||||
const EXAMPLES: Array<{ label: string, from: Place, to: Place }> = [
|
||||
{
|
||||
label: 'Lille → Paris',
|
||||
from: { label: 'Lille, France', lat: 50.6292, lon: 3.0573 },
|
||||
to: { label: 'Paris, France', lat: 48.8566, lon: 2.3522 },
|
||||
},
|
||||
{
|
||||
label: 'Grenoble → Marseille',
|
||||
from: { label: 'Grenoble, France', lat: 45.1885, lon: 5.7245 },
|
||||
to: { label: 'Marseille, France', lat: 43.2965, lon: 5.3698 },
|
||||
},
|
||||
{
|
||||
label: 'Bordeaux → Toulouse',
|
||||
from: { label: 'Bordeaux, France', lat: 44.8378, lon: -0.5792 },
|
||||
to: { label: 'Toulouse, France', lat: 43.6047, lon: 1.4442 },
|
||||
},
|
||||
]
|
||||
|
||||
const LABELS: Record<Option['mode'], string> = {
|
||||
electric_car: 'Voiture électrique',
|
||||
bike: 'Vélo',
|
||||
train: 'Train',
|
||||
bus: 'Bus & car',
|
||||
}
|
||||
|
||||
const SHORT: Record<Option['mode'], string> = {
|
||||
electric_car: 'Voiture',
|
||||
bike: 'Vélo',
|
||||
train: 'Train',
|
||||
bus: 'Bus',
|
||||
}
|
||||
|
||||
const COLORS: Record<Option['mode'], string> = {
|
||||
electric_car: '#6b3fa0',
|
||||
bike: '#0e7c5a',
|
||||
train: '#1f4fa8',
|
||||
bus: '#b8621b',
|
||||
}
|
||||
|
||||
const SEGMENT_LABELS: Record<string, string> = {
|
||||
HIGHSPEED_RAIL: 'TGV',
|
||||
LONG_DISTANCE: 'Train',
|
||||
NIGHT_RAIL: 'Train de nuit',
|
||||
REGIONAL_RAIL: 'TER',
|
||||
REGIONAL_FAST_RAIL: 'TER',
|
||||
RAIL: 'Train',
|
||||
SUBWAY: 'Métro',
|
||||
METRO: 'Métro',
|
||||
TRAM: 'Tram',
|
||||
BUS: 'Bus',
|
||||
COACH: 'Car',
|
||||
TROLLEYBUS: 'Trolleybus',
|
||||
WALK: 'Marche',
|
||||
}
|
||||
|
||||
const maxCo2 = computed(() => Math.max(1, ...(options.value ?? []).map(o => o.co2Grams ?? 0)))
|
||||
|
||||
const traces = computed(() =>
|
||||
(options.value ?? [])
|
||||
.filter(o => o.geometry?.length)
|
||||
.map(o => ({ mode: o.mode, color: COLORS[o.mode], geometry: o.geometry! })),
|
||||
)
|
||||
|
||||
/** Metadonnees affichees dans les bulles ancrees sur chaque trace. */
|
||||
const metas = computed(() =>
|
||||
(options.value ?? [])
|
||||
.filter(o => o.geometry?.length && !o.unavailable && o.durationMin !== undefined && o.co2Grams !== undefined)
|
||||
.map(o => ({
|
||||
mode: o.mode,
|
||||
color: COLORS[o.mode],
|
||||
labelTop: `${duration(o.durationMin!)} · ${co2(o.co2Grams!)}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const detail = computed(() =>
|
||||
(options.value ?? []).find(o => o.mode === selected.value) ?? null,
|
||||
)
|
||||
|
||||
const bookingLink = computed(() => {
|
||||
if (!detail.value || !searched.value) return null
|
||||
return buildBookingLink(detail.value.mode, searched.value.from, searched.value.to, new Date())
|
||||
})
|
||||
|
||||
const saving = computed(() => {
|
||||
const usable = (options.value ?? []).filter(o => !o.unavailable && o.co2Grams !== undefined)
|
||||
if (usable.length < 2) return null
|
||||
const min = Math.min(...usable.map(o => o.co2Grams!))
|
||||
const max = Math.max(...usable.map(o => o.co2Grams!))
|
||||
if (max - min < 500) return null
|
||||
return { kg: (max - min) / 1000, ratio: min > 0 ? Math.round(max / min) : null }
|
||||
})
|
||||
|
||||
function barWidth(o: Option) {
|
||||
if (o.unavailable || o.co2Grams === undefined) return 0
|
||||
return Math.max(1.5, (o.co2Grams / maxCo2.value) * 100)
|
||||
}
|
||||
|
||||
const duration = (min: number) => {
|
||||
const h = Math.floor(min / 60)
|
||||
return h > 0 ? `${h} h ${String(min % 60).padStart(2, '0')}` : `${min} min`
|
||||
}
|
||||
|
||||
const co2 = (g: number) => (g >= 1000 ? `${(g / 1000).toFixed(1)} kg` : `${g} g`)
|
||||
|
||||
const heure = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function swap() {
|
||||
const tmp = from.value
|
||||
from.value = to.value
|
||||
to.value = tmp
|
||||
}
|
||||
|
||||
async function compare() {
|
||||
if (!canCompare.value) return
|
||||
pending.value = true
|
||||
error.value = null
|
||||
selected.value = null
|
||||
try {
|
||||
options.value = await $fetch<Option[]>('/api/compare', {
|
||||
method: 'POST',
|
||||
body: { from: from.value, to: to.value },
|
||||
})
|
||||
searched.value = { from: from.value!, to: to.value! }
|
||||
formOpen.value = false
|
||||
}
|
||||
catch {
|
||||
error.value = 'Impossible de calculer ce trajet.'
|
||||
}
|
||||
finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runExample(e: typeof EXAMPLES[number]) {
|
||||
from.value = e.from
|
||||
to.value = e.to
|
||||
await nextTick()
|
||||
await compare()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
options.value = null
|
||||
searched.value = null
|
||||
selected.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-dvh flex-col bg-paper text-ink md:flex-row">
|
||||
<div
|
||||
class="shrink-0 border-b-2 border-ink md:h-auto md:flex-1 md:border-b-0 md:border-l-2"
|
||||
:class="options ? 'h-[30dvh]' : 'hidden md:block'"
|
||||
>
|
||||
<ClientOnly>
|
||||
<RouteMap
|
||||
:traces="traces"
|
||||
:metas="metas"
|
||||
:from="searched ? [searched.from.lon, searched.from.lat] : null"
|
||||
:to="searched ? [searched.to.lon, searched.to.lat] : null"
|
||||
:highlight="selected"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<aside class="flex min-h-0 flex-1 flex-col md:order-first md:h-full md:w-[380px] md:flex-none lg:w-[440px]">
|
||||
<header class="shrink-0 border-b border-rule bg-paper-2">
|
||||
<div v-if="!options" class="px-4 pb-3 pt-5">
|
||||
<h1>
|
||||
<img src="/logo.png" alt="Le Chemin Vert" class="mx-auto w-48 max-w-full">
|
||||
</h1>
|
||||
<p class="mt-1 text-center font-semibold">
|
||||
Quatre chemins, un seul demain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center gap-2 px-4 py-2">
|
||||
<img src="/logo.png" alt="Le Chemin Vert" class="h-8 w-8 shrink-0 object-contain">
|
||||
<span class="text-sm font-semibold">Le Chemin Vert</span>
|
||||
<button type="button" class="ml-auto text-xs underline underline-offset-4" @click="reset">
|
||||
Nouveau trajet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 px-4 pb-3" :class="{ 'hidden md:block': !formOpen && options }">
|
||||
<AddressField id="from" v-model="from" label="Départ" />
|
||||
<AddressField id="to" v-model="to" label="Arrivée" />
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="bg-ink px-4 py-2 text-paper disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="!canCompare || pending"
|
||||
@click="compare"
|
||||
>
|
||||
{{ pending ? 'Calcul…' : 'Comparer' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm underline underline-offset-4 disabled:opacity-40"
|
||||
:disabled="!from && !to"
|
||||
@click="swap"
|
||||
>
|
||||
Inverser
|
||||
</button>
|
||||
|
||||
<span v-if="!canCompare" class="text-xs text-ink-soft">
|
||||
Choisissez dans la liste.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
v-if="!formOpen && options && searched"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 border-b border-rule bg-paper-2 px-4 py-3 text-left text-sm md:hidden"
|
||||
@click="formOpen = true"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span class="text-ink-soft">Trajet :</span>
|
||||
<span class="font-medium">{{ searched.from.label }} → {{ searched.to.label }}</span>
|
||||
</span>
|
||||
<span aria-hidden="true" class="shrink-0 text-ink-soft">Modifier ›</span>
|
||||
</button>
|
||||
|
||||
<p v-if="error" role="alert" class="m-4 border-l-4 border-ink bg-paper-2 p-3 text-sm">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-else-if="!options && !pending" class="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<p class="text-xs uppercase tracking-widest text-ink-soft">
|
||||
Essayez un trajet
|
||||
</p>
|
||||
<ul class="mt-2 space-y-2">
|
||||
<li v-for="e in EXAMPLES" :key="e.label">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full border border-rule px-3 py-2 text-left text-sm transition-colors hover:border-ink hover:bg-paper-2"
|
||||
@click="runExample(e)"
|
||||
>
|
||||
{{ e.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4 text-xs leading-relaxed text-ink-soft">
|
||||
Chaque mode est calculé avec les données réelles des exploitants,
|
||||
et son empreinte avec les facteurs d'émission de l'ADEME.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-else-if="pending" class="p-4 text-sm text-ink-soft">
|
||||
Interrogation des réseaux…
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="options && searched && !selected"
|
||||
class="scroll-soft min-h-0 flex-1 overflow-y-auto px-4 py-3"
|
||||
>
|
||||
<h2 class="text-xs text-ink-soft">
|
||||
{{ searched.from.label }} → {{ searched.to.label }}
|
||||
</h2>
|
||||
|
||||
<p v-if="saving" class="mt-2 border-l-4 border-bike bg-paper-2 p-2 text-xs leading-relaxed">
|
||||
Jusqu'à <strong class="tnum">{{ saving.kg.toFixed(1) }} kg</strong> de CO₂e
|
||||
d'écart entre le mode le plus sobre et le plus émetteur<span v-if="saving.ratio">,
|
||||
soit un facteur {{ saving.ratio }}</span>.
|
||||
</p>
|
||||
|
||||
<ul class="mt-3 border border-rule">
|
||||
<li v-for="o in options" :key="o.mode" class="border-b border-rule last:border-b-0">
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-paper-2 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
:disabled="o.unavailable"
|
||||
@click="selected = o.mode"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 transition-all duration-500"
|
||||
:style="{ width: `${barWidth(o)}%`, background: COLORS[o.mode], opacity: 0.16 }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="relative h-4 w-4 shrink-0"
|
||||
:style="{ background: COLORS[o.mode] }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span class="relative min-w-0 flex-1">
|
||||
<span class="block truncate font-semibold">{{ LABELS[o.mode] }}</span>
|
||||
<span v-if="o.unavailable" class="block truncate text-xs font-normal text-ink-soft">
|
||||
{{ o.reason }}
|
||||
</span>
|
||||
<span v-else class="block text-xs font-normal text-ink-soft">
|
||||
{{ o.distanceKm }} km<span v-if="o.transfers"> · {{ o.transfers }} corresp.</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span v-if="!o.unavailable" class="tnum relative shrink-0 text-right">
|
||||
<span class="block">{{ duration(o.durationMin!) }}</span>
|
||||
<span class="block text-xs font-normal text-ink-soft">{{ co2(o.co2Grams!) }} CO₂e</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!o.unavailable"
|
||||
class="relative shrink-0 text-3xl font-light leading-none text-ink-soft"
|
||||
aria-hidden="true"
|
||||
>›</span>
|
||||
</button>
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3 text-center text-xs text-ink-soft">
|
||||
Touchez un mode pour voir son trajet détaillé
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="options && searched && detail">
|
||||
<div class="shrink-0 border-b border-rule px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-sm underline underline-offset-4"
|
||||
@click="selected = null"
|
||||
>
|
||||
<span class="text-2xl font-light leading-none" aria-hidden="true">‹</span>
|
||||
Comparer les quatre modes
|
||||
</button>
|
||||
|
||||
<ul class="mt-2 flex gap-1">
|
||||
<li v-for="o in options" :key="o.mode" class="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full border-t-4 px-1 py-1.5 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:style="{ borderColor: COLORS[o.mode] }"
|
||||
:class="selected === o.mode ? 'bg-paper-2 font-semibold' : 'hover:bg-paper-2'"
|
||||
:disabled="o.unavailable"
|
||||
:aria-current="selected === o.mode ? 'true' : undefined"
|
||||
@click="selected = o.mode"
|
||||
>
|
||||
<span class="block truncate text-xs">{{ SHORT[o.mode] }}</span>
|
||||
<span v-if="!o.unavailable" class="tnum block text-[11px] text-ink-soft">
|
||||
{{ duration(o.durationMin!) }}
|
||||
</span>
|
||||
<span v-else class="block text-[11px] text-ink-soft">—</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="scroll-soft min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<h2 class="text-lg font-semibold">{{ LABELS[detail.mode] }}</h2>
|
||||
<p class="tnum mt-0.5 text-sm text-ink-soft">
|
||||
{{ duration(detail.durationMin!) }} · {{ detail.distanceKm }} km ·
|
||||
{{ co2(detail.co2Grams!) }} CO₂e
|
||||
</p>
|
||||
|
||||
<aside
|
||||
v-if="detail.estimated"
|
||||
class="mt-3 border-l-4 border-ink bg-paper-2 p-3 text-xs leading-relaxed"
|
||||
role="note"
|
||||
>
|
||||
<strong>Estimation indicative.</strong>
|
||||
Notre référentiel ferroviaire ouvert (Transitous) ne couvre pas ce trajet.
|
||||
Ordre de grandeur calculé à partir de la distance à vol d'oiseau et d'une
|
||||
vitesse pratique de 90 km/h. Les horaires et le prix réels sont à vérifier
|
||||
chez le transporteur.
|
||||
</aside>
|
||||
|
||||
<a
|
||||
v-if="bookingLink"
|
||||
:href="bookingLink!.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-3 inline-block bg-ink px-4 py-2 text-sm text-paper hover:opacity-90"
|
||||
:aria-label="`${bookingLink!.label} pour ${LABELS[detail.mode]} de ${searched.from.label} à ${searched.to.label}`"
|
||||
>
|
||||
{{ bookingLink!.label }} →
|
||||
</a>
|
||||
<p v-if="bookingLink" class="mt-2 text-[11px] text-ink-soft">
|
||||
Vous serez redirigé vers {{ bookingLink!.provider }}.
|
||||
</p>
|
||||
|
||||
<ol v-if="detail.segments?.length" class="mt-4 space-y-3">
|
||||
<li
|
||||
v-for="(s, i) in detail.segments"
|
||||
:key="i"
|
||||
class="flex gap-3 border-l-4 pl-3"
|
||||
:style="{ borderColor: s.color || 'var(--color-rule)' }"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-1.5">
|
||||
<span
|
||||
v-if="s.mode !== 'WALK'"
|
||||
class="px-1.5 py-0.5 text-xs font-semibold"
|
||||
:style="{
|
||||
background: s.color || 'var(--color-ink)',
|
||||
color: s.textColor || 'var(--color-paper)',
|
||||
}"
|
||||
>{{ s.line }}</span>
|
||||
|
||||
<span class="text-sm font-medium">
|
||||
{{ SEGMENT_LABELS[s.mode] ?? s.mode }}
|
||||
</span>
|
||||
|
||||
<span v-if="s.operator" class="text-xs text-ink-soft">
|
||||
{{ s.operator }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="s.from || s.to" class="mt-0.5 text-sm text-ink-soft">
|
||||
{{ s.from }}<span v-if="s.from && s.to"> → </span>{{ s.to }}
|
||||
</p>
|
||||
|
||||
<p v-if="s.headsign" class="text-xs text-ink-soft">
|
||||
direction {{ s.headsign }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tnum shrink-0 text-right text-xs text-ink-soft">
|
||||
<div>{{ heure(s.departure) }}</div>
|
||||
<div>{{ s.durationMin }} min</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-else class="mt-4 text-sm text-ink-soft">
|
||||
Trajet direct, sans correspondance. Son tracé est isolé sur la carte.
|
||||
</p>
|
||||
|
||||
<footer class="mt-6 border-t border-rule pt-3 text-[11px] leading-relaxed text-ink-soft">
|
||||
OpenRouteService, Transitous et ADEME Base Carbone. Les données GTFS
|
||||
ouvertes peuvent comporter des imprécisions de classification.
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
31
app/assets/css/main.css
Normal file
31
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-paper: #f2f4ef;
|
||||
--color-paper-2: #e5e9df;
|
||||
--color-rule: #c9d1c4;
|
||||
--color-ink: #10231c;
|
||||
--color-ink-soft: #4a5a52;
|
||||
--color-car: #6b3fa0;
|
||||
--color-bus: #b8621b;
|
||||
--color-train: #1f4fa8;
|
||||
--color-bike: #0e7c5a;
|
||||
}
|
||||
|
||||
/* La carte occupe le fond : sans hauteur explicite, elle ne s'affiche pas. */
|
||||
html, body, #__nuxt {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Barre de defilement discrete dans le panneau lateral. */
|
||||
.scroll-soft::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.scroll-soft::-webkit-scrollbar-thumb {
|
||||
background: var(--color-rule);
|
||||
border-radius: 4px;
|
||||
}
|
||||
133
app/components/AddressField.vue
Normal file
133
app/components/AddressField.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<script setup lang="ts">
|
||||
import type { Place } from '#shared/types'
|
||||
|
||||
defineProps<{ label: string, id: string }>()
|
||||
const model = defineModel<Place | null>({ default: null })
|
||||
|
||||
const query = ref('')
|
||||
const results = ref<Place[]>([])
|
||||
const open = ref(false)
|
||||
const active = ref(-1)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
/** Empeche une ecriture programmatique de relancer une recherche. */
|
||||
let skipNext = false
|
||||
|
||||
/**
|
||||
* Le champ doit refleter le modele meme quand il change de l'exterieur :
|
||||
* inversion depart/arrivee, trajet d'exemple, restauration d'etat.
|
||||
* Sans cela le texte affiche ment sur ce qui est reellement selectionne.
|
||||
*/
|
||||
watch(model, (m) => {
|
||||
const label = m?.label ?? ''
|
||||
if (label === query.value) return
|
||||
skipNext = true
|
||||
query.value = label
|
||||
}, { immediate: true })
|
||||
|
||||
watch(query, (q) => {
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
return
|
||||
}
|
||||
|
||||
// Toute frappe invalide la selection : on ne calcule jamais un trajet
|
||||
// vers un lieu que l'utilisateur n'a pas explicitement confirme.
|
||||
if (model.value && q !== model.value.label) model.value = null
|
||||
|
||||
clearTimeout(timer)
|
||||
if (q.trim().length < 3) {
|
||||
results.value = []
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimeout(async () => {
|
||||
results.value = await $fetch<Place[]>('/api/geocode', { query: { q } })
|
||||
open.value = results.value.length > 0
|
||||
active.value = -1
|
||||
}, 250)
|
||||
})
|
||||
|
||||
function select(place: Place) {
|
||||
model.value = place
|
||||
skipNext = place.label !== query.value
|
||||
query.value = place.label
|
||||
open.value = false
|
||||
active.value = -1
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
active.value = (active.value + 1) % results.value.length
|
||||
}
|
||||
else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
active.value = active.value <= 0 ? results.value.length - 1 : active.value - 1
|
||||
}
|
||||
else if (e.key === 'Enter' && active.value >= 0) {
|
||||
e.preventDefault()
|
||||
select(results.value[active.value]!)
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Le delai laisse le mousedown de la liste s'executer avant la fermeture. */
|
||||
function onBlur() {
|
||||
setTimeout(() => { open.value = false }, 150)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<label :for="id" class="text-xs font-medium uppercase tracking-widest text-ink-soft">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
v-model="query"
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="`${id}-list`"
|
||||
:aria-activedescendant="active >= 0 ? `${id}-opt-${active}` : undefined"
|
||||
placeholder="Ville, gare ou adresse"
|
||||
class="mt-1 w-full border-b-2 bg-transparent py-2 text-lg outline-none"
|
||||
:class="model ? 'border-bike' : 'border-ink'"
|
||||
@keydown="onKeydown"
|
||||
@focus="open = results.length > 0"
|
||||
@blur="onBlur"
|
||||
>
|
||||
|
||||
<ul
|
||||
v-if="open"
|
||||
:id="`${id}-list`"
|
||||
role="listbox"
|
||||
class="absolute z-20 mt-1 w-full border-2 border-ink bg-paper shadow-[4px_4px_0_0_var(--color-ink)]"
|
||||
>
|
||||
<li
|
||||
v-for="(place, i) in results"
|
||||
:id="`${id}-opt-${i}`"
|
||||
:key="place.label"
|
||||
role="option"
|
||||
:aria-selected="i === active"
|
||||
class="cursor-pointer px-3 py-2 text-sm"
|
||||
:class="i === active ? 'bg-ink text-paper' : 'hover:bg-paper-2'"
|
||||
@mousedown.prevent="select(place)"
|
||||
>
|
||||
{{ place.label }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="sr-only" aria-live="polite">
|
||||
{{ open ? `${results.length} lieux proposés` : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
260
app/components/RouteMap.vue
Normal file
260
app/components/RouteMap.vue
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<script setup lang="ts">
|
||||
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl, Popup, setWorkerUrl } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import type { Coord } from '#shared/types'
|
||||
import { midpointAt } from '#shared/polyline'
|
||||
|
||||
setWorkerUrl('/maplibre-gl-worker.mjs')
|
||||
|
||||
interface Trace {
|
||||
mode: string
|
||||
color: string
|
||||
geometry: Coord[]
|
||||
}
|
||||
|
||||
interface TraceMeta {
|
||||
mode: string
|
||||
color: string
|
||||
labelTop: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
traces?: Trace[]
|
||||
metas?: TraceMeta[]
|
||||
from?: Coord | null
|
||||
to?: Coord | null
|
||||
highlight?: string | null
|
||||
}>(), {
|
||||
traces: () => [],
|
||||
metas: () => [],
|
||||
from: null,
|
||||
to: null,
|
||||
highlight: null,
|
||||
})
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null)
|
||||
let map: MapLibreMap | null = null
|
||||
let markers: Marker[] = []
|
||||
let bubbles: Map<string, Marker> = new Map()
|
||||
let drawn: string[] = []
|
||||
|
||||
/**
|
||||
* Fond raster de l'IGN Geoplateforme : service public, gratuit, sans clef.
|
||||
* Le raster n'a pas besoin de Web Worker, contrairement aux tuiles vectorielles
|
||||
* que le contexte de production empechait silencieusement de decoder.
|
||||
*/
|
||||
const STYLE = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
ign: {
|
||||
type: 'raster' as const,
|
||||
tiles: [
|
||||
'https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile'
|
||||
+ '&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png'
|
||||
+ '&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: '© <a href="https://www.ign.fr/">IGN</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'ign', type: 'raster' as const, source: 'ign' }],
|
||||
}
|
||||
|
||||
const FRANCE: Coord = [2.5, 46.6]
|
||||
|
||||
function clear() {
|
||||
if (!map) return
|
||||
for (const m of markers) m.remove()
|
||||
markers = []
|
||||
for (const b of bubbles.values()) b.remove()
|
||||
bubbles.clear()
|
||||
// On retire ce qui a reellement ete dessine, pas ce que les props contiennent
|
||||
// maintenant : les deux listes different des que le trajet change.
|
||||
for (const id of drawn) {
|
||||
if (map.getLayer(id)) map.removeLayer(id)
|
||||
if (map.getSource(id)) map.removeSource(id)
|
||||
}
|
||||
drawn = []
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!map || !props.traces.length) return
|
||||
const b = new LngLatBounds()
|
||||
|
||||
for (const t of props.traces) {
|
||||
if (t.geometry.length < 2) continue
|
||||
const id = `trace-${t.mode}`
|
||||
if (map.getLayer(id)) map.removeLayer(id)
|
||||
if (map.getSource(id)) map.removeSource(id)
|
||||
|
||||
map.addSource(id, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: t.geometry },
|
||||
},
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id,
|
||||
type: 'line',
|
||||
source: id,
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: { 'line-color': t.color, 'line-width': 4, 'line-opacity': 0.85 },
|
||||
})
|
||||
|
||||
drawn.push(id)
|
||||
for (const c of t.geometry) b.extend(c)
|
||||
}
|
||||
|
||||
for (const [coord, label] of [[props.from, 'Départ'], [props.to, 'Arrivée']] as const) {
|
||||
if (!coord) continue
|
||||
markers.push(
|
||||
new Marker({ color: '#10231c' })
|
||||
.setLngLat(coord)
|
||||
.setPopup(new Popup({ offset: 24 }).setText(label))
|
||||
.addTo(map),
|
||||
)
|
||||
b.extend(coord)
|
||||
}
|
||||
|
||||
if (!b.isEmpty()) map.fitBounds(b, { padding: 48, duration: 600 })
|
||||
drawBubbles()
|
||||
applyHighlight()
|
||||
}
|
||||
|
||||
function drawBubbles() {
|
||||
if (!map) return
|
||||
// Offset vertical par mode pour que les 4 bulles ne se superposent pas
|
||||
// quand les traces sont quasi confondues (typique des trajets moyennes distances).
|
||||
const offsets: Record<string, number> = {
|
||||
train: -46,
|
||||
bike: -16,
|
||||
bus: 14,
|
||||
electric_car: 44,
|
||||
}
|
||||
for (const t of props.traces) {
|
||||
if (t.geometry.length < 2) continue
|
||||
const meta = props.metas.find(m => m.mode === t.mode)
|
||||
if (!meta) continue
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.className = 'lcv-bubble'
|
||||
el.style.borderLeftColor = t.color
|
||||
el.innerHTML = `<span class="lcv-bubble-dot" style="background:${t.color}"></span><span>${meta.labelTop}</span>`
|
||||
|
||||
const bubble = new Marker({
|
||||
element: el,
|
||||
offset: [0, offsets[t.mode] ?? 0],
|
||||
})
|
||||
.setLngLat(midpointAt(t.geometry, 0.5))
|
||||
.addTo(map)
|
||||
|
||||
bubbles.set(t.mode, bubble)
|
||||
}
|
||||
}
|
||||
|
||||
function applyHighlight() {
|
||||
if (!map) return
|
||||
for (const t of props.traces) {
|
||||
const id = `trace-${t.mode}`
|
||||
if (map.getLayer(id)) {
|
||||
const actif = !props.highlight || props.highlight === t.mode
|
||||
map.setPaintProperty(id, 'line-opacity', actif ? 0.9 : 0.15)
|
||||
map.setPaintProperty(id, 'line-width', props.highlight === t.mode ? 6 : 4)
|
||||
}
|
||||
const bubble = bubbles.get(t.mode)
|
||||
if (bubble) {
|
||||
const el = bubble.getElement()
|
||||
// Sur mobile, on n affiche qu une seule bulle max : celle du mode
|
||||
// selectionne, sinon aucune. Les 4 bulles superposees etaient illisibles.
|
||||
const isMobile = window.matchMedia('(max-width: 767px)').matches
|
||||
const cachee = isMobile
|
||||
? !props.highlight || props.highlight !== t.mode
|
||||
: Boolean(props.highlight && props.highlight !== t.mode)
|
||||
el.style.display = cachee ? 'none' : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Le style peut ne pas etre pret quand les traces arrivent : on attend l'evenement. */
|
||||
function redraw() {
|
||||
if (!map) return
|
||||
const run = () => {
|
||||
try {
|
||||
clear()
|
||||
draw()
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[carte] echec du trace', e)
|
||||
}
|
||||
}
|
||||
if (map.isStyleLoaded()) run()
|
||||
else map.once('idle', run)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) return
|
||||
map = new MapLibreMap({
|
||||
container: container.value,
|
||||
style: STYLE,
|
||||
center: props.from ?? FRANCE,
|
||||
zoom: props.from ? 8 : 5,
|
||||
})
|
||||
|
||||
|
||||
|
||||
map.addControl(new NavigationControl({ showCompass: false }), 'bottom-right')
|
||||
map.on('error', e => console.error('[carte]', e.error ?? e))
|
||||
map.on('load', redraw)
|
||||
window.addEventListener('resize', applyHighlight)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', applyHighlight)
|
||||
map?.remove()
|
||||
map = null
|
||||
})
|
||||
|
||||
watch(() => props.traces, redraw, { deep: true })
|
||||
watch(() => props.highlight, applyHighlight)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full w-full"
|
||||
role="img"
|
||||
aria-label="Carte des itinéraires comparés"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.lcv-bubble {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: white;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #10231c;
|
||||
border: 1px solid #ccc;
|
||||
border-left-width: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lcv-bubble-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.lcv-bubble.dimmed {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: lecheminvert-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NUXT_ORS_API_KEY: ${NUXT_ORS_API_KEY}
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
- edge
|
||||
|
||||
networks:
|
||||
edge:
|
||||
name: edge
|
||||
external: true
|
||||
43
nuxt.config.ts
Normal file
43
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
css: ['~/assets/css/main.css'],
|
||||
typescript: { strict: true },
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
optimizeDeps: {
|
||||
// MapLibre rend les tuiles dans un Web Worker que l'optimiseur de Vite
|
||||
// ne sait pas produire : sans cette exclusion, la carte reste vide en dev.
|
||||
exclude: ['maplibre-gl'],
|
||||
},
|
||||
worker: {
|
||||
// Le worker doit etre un module ES pour survivre au build de production.
|
||||
// Sans cela le style se charge mais aucune tuile n'est jamais decodee.
|
||||
format: 'es',
|
||||
},
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'fr' },
|
||||
title: 'Le Chemin Vert — comparateur d\'itinéraires bas carbone',
|
||||
link: [{ rel: 'icon', type: 'image/png', href: '/favicon.png' }],
|
||||
meta: [
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Comparez la durée, le coût et les émissions de CO₂ de votre trajet en voiture électrique, bus, train et vélo.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Les clefs declarees hors de `public` restent cote serveur.
|
||||
// Nuxt remplit orsApiKey depuis la variable NUXT_ORS_API_KEY.
|
||||
runtimeConfig: {
|
||||
orsApiKey: '',
|
||||
},
|
||||
})
|
||||
11753
package-lock.json
generated
Normal file
11753
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
package.json
Normal file
37
package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "lecheminvert",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:unit": "vitest --project unit",
|
||||
"test:nuxt": "vitest --project nuxt",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/test-utils": "^4.1.0",
|
||||
"maplibre-gl": "^6.0.0",
|
||||
"nuxt": "^4.5.1",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"happy-dom": "^20.11.1",
|
||||
"playwright-core": "^1.62.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
24
playwright.config.ts
Normal file
24
playwright.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import type { ConfigOptions } from '@nuxt/test-utils/playwright'
|
||||
|
||||
export default defineConfig<ConfigOptions>({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
nuxt: {
|
||||
rootDir: fileURLToPath(new URL('.', import.meta.url)),
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
6
public/maplibre-gl-shared.mjs
Normal file
6
public/maplibre-gl-shared.mjs
Normal file
File diff suppressed because one or more lines are too long
6
public/maplibre-gl-worker.mjs
Normal file
6
public/maplibre-gl-worker.mjs
Normal file
File diff suppressed because one or more lines are too long
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-Agent: *
|
||||
Disallow:
|
||||
97
server/api/compare.post.ts
Normal file
97
server/api/compare.post.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { z } from 'zod'
|
||||
import { EMISSION_FACTORS } from '#shared/emissions'
|
||||
import { thin } from '#shared/polyline'
|
||||
import { estimateTrainTrip } from '../utils/estimate'
|
||||
import type { Coord } from '#shared/types'
|
||||
|
||||
const BodySchema = z.object({
|
||||
from: z.object({ lat: z.number(), lon: z.number() }),
|
||||
to: z.object({ lat: z.number(), lon: z.number() }),
|
||||
})
|
||||
|
||||
interface OrsResponse {
|
||||
features: Array<{
|
||||
properties: { summary: { distance: number, duration: number } }
|
||||
geometry: { coordinates: Coord[] }
|
||||
}>
|
||||
}
|
||||
|
||||
/** Interroge OpenRouteService pour un profil donne. */
|
||||
async function route(profile: string, body: unknown, key: string) {
|
||||
const data = await $fetch<OrsResponse>(
|
||||
`https://api.openrouteservice.org/v2/directions/${profile}/geojson`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: key,
|
||||
// ORS refuse de repondre si on n'annonce pas accepter ce format precis.
|
||||
Accept: 'application/geo+json',
|
||||
},
|
||||
body,
|
||||
},
|
||||
)
|
||||
|
||||
const feature = data.features[0]
|
||||
if (!feature) throw new Error('Itinéraire introuvable')
|
||||
|
||||
return {
|
||||
km: feature.properties.summary.distance / 1000,
|
||||
min: feature.properties.summary.duration / 60,
|
||||
// ORS renvoie deja du GeoJSON [lon, lat] : rien a decoder, juste a alleger.
|
||||
geometry: thin(feature.geometry.coordinates, 800),
|
||||
}
|
||||
}
|
||||
|
||||
const unavailable = (mode: string, reason: string) => ({ mode, unavailable: true, reason })
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsed = BodySchema.safeParse(await readBody(event))
|
||||
if (!parsed.success) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Départ ou arrivée invalide.' })
|
||||
}
|
||||
|
||||
const { from, to } = parsed.data
|
||||
const { orsApiKey } = useRuntimeConfig(event)
|
||||
const body = { coordinates: [[from.lon, from.lat], [to.lon, to.lat]] }
|
||||
|
||||
// allSettled et non all : un fournisseur en panne ne doit pas vider la page.
|
||||
const [car, bike, pt] = await Promise.allSettled([
|
||||
route('driving-car', body, orsApiKey),
|
||||
route('cycling-regular', body, orsApiKey),
|
||||
publicTransport(from, to, new Date()),
|
||||
])
|
||||
|
||||
const options = []
|
||||
|
||||
options.push(car.status === 'fulfilled'
|
||||
? {
|
||||
mode: 'electric_car',
|
||||
distanceKm: Math.round(car.value.km * 10) / 10,
|
||||
durationMin: Math.round(car.value.min),
|
||||
co2Grams: Math.round(car.value.km * EMISSION_FACTORS.electric_car),
|
||||
geometry: car.value.geometry,
|
||||
}
|
||||
: unavailable('electric_car', 'Aucune route carrossable trouvée.'))
|
||||
|
||||
options.push(bike.status === 'fulfilled'
|
||||
? {
|
||||
mode: 'bike',
|
||||
distanceKm: Math.round(bike.value.km * 10) / 10,
|
||||
durationMin: Math.round(bike.value.min),
|
||||
co2Grams: 0,
|
||||
geometry: bike.value.geometry,
|
||||
}
|
||||
: unavailable('bike', 'Aucun itinéraire cyclable trouvé.'))
|
||||
|
||||
if (pt.status === 'fulfilled') {
|
||||
options.push(pt.value.train ?? estimateTrainTrip(from, to))
|
||||
options.push(pt.value.bus ?? unavailable('bus', 'Pas de ligne régulière.'))
|
||||
}
|
||||
else {
|
||||
options.push(unavailable('train', 'Horaires indisponibles.'))
|
||||
options.push(unavailable('bus', 'Horaires indisponibles.'))
|
||||
}
|
||||
|
||||
return options.sort((a, b) =>
|
||||
('co2Grams' in a ? a.co2Grams : Infinity) - ('co2Grams' in b ? b.co2Grams : Infinity))
|
||||
})
|
||||
10
server/api/geocode.get.ts
Normal file
10
server/api/geocode.get.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from 'zod'
|
||||
import type { Place } from '#shared/types'
|
||||
|
||||
const QuerySchema = z.object({ q: z.string().min(3).max(200) })
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Place[]> => {
|
||||
const parsed = QuerySchema.safeParse(getQuery(event))
|
||||
if (!parsed.success) return []
|
||||
return searchPlaces(parsed.data.q)
|
||||
})
|
||||
13
server/api/health.get.ts
Normal file
13
server/api/health.get.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Cible du health check de l'ALB.
|
||||
* Volontairement sans dependance externe : si OpenRouteService tombe,
|
||||
* la tache reste saine et continue de servir ce qu'elle peut.
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
return {
|
||||
status: 'ok',
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
version: process.env.APP_VERSION ?? 'dev',
|
||||
}
|
||||
})
|
||||
48
server/utils/estimate.ts
Normal file
48
server/utils/estimate.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { EMISSION_FACTORS } from '#shared/emissions'
|
||||
import type { Coord } from '#shared/types'
|
||||
|
||||
const TRAIN_AVG_KMH = 90
|
||||
|
||||
/**
|
||||
* Distance a vol d oiseau entre deux points geographiques en km.
|
||||
* Formule de Haversine, rayon terrestre moyen 6371 km.
|
||||
*/
|
||||
function haversineKm(a: { lat: number, lon: number }, b: { lat: number, lon: number }): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180
|
||||
const R = 6371
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLon = toRad(b.lon - a.lon)
|
||||
const s = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(s))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fabrique une estimation train quand Transitous ne couvre pas le trajet.
|
||||
* Distance vol d oiseau x 1.2 pour approximer le detour ferroviaire,
|
||||
* vitesse pratique moyenne 90 km/h (compromis TGV/TER), facteur ADEME train
|
||||
* generique. C est un ordre de grandeur, pas un horaire.
|
||||
*/
|
||||
export function estimateTrainTrip(
|
||||
from: { lat: number, lon: number },
|
||||
to: { lat: number, lon: number },
|
||||
) {
|
||||
const straightKm = haversineKm(from, to)
|
||||
// Facteur 1.2 : les voies ferrees suivent rarement une ligne droite.
|
||||
const distanceKm = Math.round(straightKm * 1.2 * 10) / 10
|
||||
const durationMin = Math.round((distanceKm / TRAIN_AVG_KMH) * 60)
|
||||
const co2Grams = Math.round(distanceKm * EMISSION_FACTORS.train)
|
||||
|
||||
// Geometrie ligne droite [depart, arrivee] pour que la carte affiche
|
||||
// quelque chose de coherent avec l estimation textuelle.
|
||||
const geometry: Coord[] = [[from.lon, from.lat], [to.lon, to.lat]]
|
||||
|
||||
return {
|
||||
mode: 'train' as const,
|
||||
distanceKm,
|
||||
durationMin,
|
||||
co2Grams,
|
||||
geometry,
|
||||
estimated: true,
|
||||
}
|
||||
}
|
||||
82
server/utils/geocoding.ts
Normal file
82
server/utils/geocoding.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { Place } from '#shared/types'
|
||||
|
||||
const UA = 'lecheminvert.tech (adam@belkacemi.me)'
|
||||
|
||||
interface BanResponse {
|
||||
features: Array<{
|
||||
geometry: { coordinates: [number, number] }
|
||||
properties: { label: string }
|
||||
}>
|
||||
}
|
||||
|
||||
interface PhotonResponse {
|
||||
features: Array<{
|
||||
geometry: { coordinates: [number, number] }
|
||||
properties: {
|
||||
name?: string
|
||||
city?: string
|
||||
country?: string
|
||||
street?: string
|
||||
housenumber?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
/** La BAN est excellente en France, et aveugle partout ailleurs. */
|
||||
async function fromBan(q: string): Promise<Place[]> {
|
||||
const url = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(q)}&limit=5&autocomplete=1`
|
||||
const data = await $fetch<BanResponse>(url, { headers: { 'User-Agent': UA } })
|
||||
return data.features.map(f => ({
|
||||
label: f.properties.label,
|
||||
lon: f.geometry.coordinates[0],
|
||||
lat: f.geometry.coordinates[1],
|
||||
}))
|
||||
}
|
||||
|
||||
/** Photon couvre le monde entier via OpenStreetMap, sans clé. */
|
||||
async function fromPhoton(q: string): Promise<Place[]> {
|
||||
const url = `https://photon.komoot.io/api/?q=${encodeURIComponent(q)}&limit=5&lang=fr`
|
||||
const data = await $fetch<PhotonResponse>(url, { headers: { 'User-Agent': UA } })
|
||||
return data.features.map((f) => {
|
||||
const p = f.properties
|
||||
const rue = [p.housenumber, p.street].filter(Boolean).join(' ')
|
||||
const parts = [rue || p.name, p.city !== p.name ? p.city : undefined, p.country]
|
||||
return {
|
||||
label: parts.filter(Boolean).join(', '),
|
||||
lon: f.geometry.coordinates[0],
|
||||
lat: f.geometry.coordinates[1],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Deux entrees sont identiques si elles partagent le nom ou la position. */
|
||||
export function dedupe(places: Place[]): Place[] {
|
||||
const seenLabel = new Set<string>()
|
||||
const seenCoord = new Set<string>()
|
||||
return places.filter((p) => {
|
||||
const label = p.label.toLowerCase().trim()
|
||||
const coord = `${p.lat.toFixed(2)},${p.lon.toFixed(2)}`
|
||||
if (seenLabel.has(label) || seenCoord.has(coord)) return false
|
||||
seenLabel.add(label)
|
||||
seenCoord.add(coord)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Les deux sources sont entrelacees plutot que concatenees : sans cela,
|
||||
* les cinq resultats francais de la BAN enterrent systematiquement la
|
||||
* vraie ville etrangere trouvee par Photon.
|
||||
*/
|
||||
export async function searchPlaces(q: string): Promise<Place[]> {
|
||||
const [ban, photon] = await Promise.allSettled([fromBan(q), fromPhoton(q)])
|
||||
const a = ban.status === 'fulfilled' ? ban.value : []
|
||||
const b = photon.status === 'fulfilled' ? photon.value : []
|
||||
|
||||
const merged: Place[] = []
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if (a[i]) merged.push(a[i]!)
|
||||
if (b[i]) merged.push(b[i]!)
|
||||
}
|
||||
return dedupe(merged).slice(0, 8)
|
||||
}
|
||||
219
server/utils/transitous.ts
Normal file
219
server/utils/transitous.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { DEFAULT_LEG_FACTOR, LEG_FACTORS } from '#shared/emissions'
|
||||
import { decodePolyline, thin } from '#shared/polyline'
|
||||
import type { Coord, Segment } from '#shared/types'
|
||||
|
||||
interface MotisPlace {
|
||||
lat: number
|
||||
lon: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface MotisLeg {
|
||||
mode: string
|
||||
duration?: number
|
||||
distance?: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
from: MotisPlace
|
||||
to: MotisPlace
|
||||
headsign?: string
|
||||
routeColor?: string
|
||||
routeTextColor?: string
|
||||
routeShortName?: string
|
||||
routeLongName?: string
|
||||
displayName?: string
|
||||
agencyName?: string
|
||||
agencyUrl?: string
|
||||
realTime?: boolean
|
||||
legGeometry?: { points: string, precision: number, length: number }
|
||||
}
|
||||
|
||||
interface MotisItinerary {
|
||||
duration: number
|
||||
transfers: number
|
||||
legs: MotisLeg[]
|
||||
}
|
||||
|
||||
type Family = 'rail' | 'urban' | 'walk'
|
||||
|
||||
const RAIL = new Set(['HIGHSPEED_RAIL', 'LONG_DISTANCE', 'NIGHT_RAIL', 'REGIONAL_RAIL', 'REGIONAL_FAST_RAIL', 'RAIL'])
|
||||
const URBAN = new Set(['BUS', 'COACH', 'SUBWAY', 'METRO', 'TRAM', 'TROLLEYBUS'])
|
||||
|
||||
export function family(mode: string): Family {
|
||||
if (RAIL.has(mode)) return 'rail'
|
||||
if (URBAN.has(mode)) return 'urban'
|
||||
return 'walk'
|
||||
}
|
||||
|
||||
/** Distance orthodromique entre deux points, en kilometres. */
|
||||
export function haversineKm(a: MotisPlace, b: MotisPlace): number {
|
||||
const R = 6371.0088
|
||||
const rad = (d: number) => (d * Math.PI) / 180
|
||||
const dLat = rad(b.lat - a.lat)
|
||||
const dLon = rad(b.lon - a.lon)
|
||||
const h = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
||||
/** Transitous ne renseigne pas toujours distance et duration : on recalcule. */
|
||||
export function legKm(leg: MotisLeg): number {
|
||||
return leg.distance !== undefined ? leg.distance / 1000 : haversineKm(leg.from, leg.to)
|
||||
}
|
||||
|
||||
export function legMin(leg: MotisLeg): number {
|
||||
if (leg.duration !== undefined) return leg.duration / 60
|
||||
return (Date.parse(leg.endTime) - Date.parse(leg.startTime)) / 60000
|
||||
}
|
||||
|
||||
/** Le mode qui pese le plus longtemps decide de la nature du trajet. */
|
||||
export function dominant(itinerary: MotisItinerary): Family {
|
||||
const weight: Record<Family, number> = { rail: 0, urban: 0, walk: 0 }
|
||||
for (const leg of itinerary.legs) weight[family(leg.mode)] += legMin(leg)
|
||||
if (weight.rail > 0 && weight.rail >= weight.urban) return 'rail'
|
||||
return weight.urban > 0 ? 'urban' : 'walk'
|
||||
}
|
||||
|
||||
/** Les couleurs GTFS sont fournies sans diese : « fdc41f » et non « #fdc41f ». */
|
||||
function hexColor(raw?: string): string | undefined {
|
||||
if (!raw || !/^[0-9a-f]{6}$/i.test(raw)) return undefined
|
||||
return `#${raw}`
|
||||
}
|
||||
|
||||
/** MOTIS annonce sa precision dans la reponse : on ne la devine jamais. */
|
||||
export function legGeometry(leg: MotisLeg): Coord[] | undefined {
|
||||
const g = leg.legGeometry
|
||||
if (!g?.points) return undefined
|
||||
return thin(decodePolyline(g.points, g.precision ?? 5), 400)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme un troncon brut en information affichable.
|
||||
* Les marches de moins de deux minutes sont ignorees : elles polluent
|
||||
* l'affichage sans rien apprendre au voyageur.
|
||||
*/
|
||||
export function toSegment(leg: MotisLeg): Segment | null {
|
||||
const minutes = Math.round(legMin(leg))
|
||||
if (family(leg.mode) === 'walk' && minutes < 2) return null
|
||||
|
||||
// MOTIS nomme les extremites du trajet START et END : sans interet pour l'usager.
|
||||
const nom = (p: MotisPlace) => (p.name === 'START' || p.name === 'END' ? '' : p.name ?? '')
|
||||
|
||||
return {
|
||||
mode: leg.mode,
|
||||
line: leg.displayName || leg.routeShortName || (leg.mode === 'WALK' ? 'Marche' : ''),
|
||||
lineLong: leg.routeLongName || undefined,
|
||||
operator: leg.agencyName || undefined,
|
||||
operatorUrl: leg.agencyUrl || undefined,
|
||||
color: hexColor(leg.routeColor),
|
||||
textColor: hexColor(leg.routeTextColor),
|
||||
headsign: leg.headsign || undefined,
|
||||
from: nom(leg.from),
|
||||
to: nom(leg.to),
|
||||
departure: leg.startTime,
|
||||
arrival: leg.endTime,
|
||||
// Un troncon dure au moins une minute : afficher « 0 min » n'aide personne.
|
||||
durationMin: Math.max(1, minutes),
|
||||
realTime: leg.realTime === true,
|
||||
geometry: legGeometry(leg),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarize(itinerary: MotisItinerary, mode: 'train' | 'bus') {
|
||||
let km = 0
|
||||
let co2 = 0
|
||||
const segments: Segment[] = []
|
||||
const geometry: Coord[] = []
|
||||
|
||||
for (const leg of itinerary.legs) {
|
||||
const d = legKm(leg)
|
||||
km += d
|
||||
co2 += (LEG_FACTORS[leg.mode] ?? DEFAULT_LEG_FACTOR) * d
|
||||
|
||||
const seg = toSegment(leg)
|
||||
if (seg) {
|
||||
segments.push(seg)
|
||||
if (seg.geometry) geometry.push(...seg.geometry)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
distanceKm: Math.round(km * 10) / 10,
|
||||
durationMin: Math.round(itinerary.duration / 60),
|
||||
co2Grams: Math.round(co2),
|
||||
transfers: itinerary.transfers,
|
||||
segments,
|
||||
geometry: thin(geometry, 800),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes autorises pour chaque famille.
|
||||
* Ces listes sont volontairement courtes : mesure faite, ajouter SUBWAY et TRAM
|
||||
* a la famille routiere fait passer MOTIS de cinq itineraires a zero. Elargir
|
||||
* l'espace de recherche ne l'enrichit pas, il l'empeche d'aboutir.
|
||||
*/
|
||||
const TRANSIT_MODES = {
|
||||
rail: 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,REGIONAL_FAST_RAIL,RAIL',
|
||||
urban: 'BUS,COACH',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Une requete par famille. MOTIS optimise sur la duree : interroge sans
|
||||
* contrainte, il ne proposera jamais un car de 4 h la ou un TGV met 1 h 37.
|
||||
* C'est en restreignant les modes qu'on obtient une vraie alternative.
|
||||
*/
|
||||
async function plan(
|
||||
from: { lat: number, lon: number },
|
||||
to: { lat: number, lon: number },
|
||||
departure: Date,
|
||||
transitModes: string,
|
||||
): Promise<MotisItinerary[]> {
|
||||
const params = new URLSearchParams({
|
||||
fromPlace: `${from.lat},${from.lon}`,
|
||||
toPlace: `${to.lat},${to.lon}`,
|
||||
time: departure.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
transitModes,
|
||||
})
|
||||
|
||||
const data = await $fetch<{ itineraries?: MotisItinerary[] }>(
|
||||
// Les virgules encodees et l'absence d'identification valent un 403.
|
||||
`https://api.transitous.org/api/v1/plan?${params.toString().replace(/%2C/g, ',')}`,
|
||||
{ headers: { 'User-Agent': 'lecheminvert.tech (adam@belkacemi.me)' } },
|
||||
)
|
||||
|
||||
return data.itineraries ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plus rapide des itineraires exploitables.
|
||||
* Les modes etant deja contraints cote requete, on ne rejette qu'un trajet
|
||||
* entierement pietonnier, que MOTIS renvoie parfois faute de mieux.
|
||||
*/
|
||||
function best(itineraries: MotisItinerary[]): MotisItinerary | undefined {
|
||||
return itineraries
|
||||
.filter(i => dominant(i) !== 'walk')
|
||||
.sort((a, b) => a.duration - b.duration)[0]
|
||||
}
|
||||
|
||||
export async function publicTransport(
|
||||
from: { lat: number, lon: number },
|
||||
to: { lat: number, lon: number },
|
||||
departure: Date,
|
||||
) {
|
||||
// Les deux familles sont interrogees en parallele : une panne d'un cote
|
||||
// ne doit pas priver l'utilisateur de l'autre.
|
||||
const [rail, urban] = await Promise.allSettled([
|
||||
plan(from, to, departure, TRANSIT_MODES.rail),
|
||||
plan(from, to, departure, TRANSIT_MODES.urban),
|
||||
])
|
||||
|
||||
const railIt = rail.status === 'fulfilled' ? best(rail.value) : undefined
|
||||
const urbanIt = urban.status === 'fulfilled' ? best(urban.value) : undefined
|
||||
|
||||
return {
|
||||
train: railIt ? summarize(railIt, 'train') : null,
|
||||
bus: urbanIt ? summarize(urbanIt, 'bus') : null,
|
||||
}
|
||||
}
|
||||
94
shared/booking.ts
Normal file
94
shared/booking.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { Place } from './types'
|
||||
|
||||
export interface BookingLink {
|
||||
url: string
|
||||
label: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
/** Normalise un nom de ville pour l'inclure dans une URL slug. */
|
||||
function slugify(label: string): string {
|
||||
return label
|
||||
.normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** Extrait le nom de ville avant la virgule ("Lille, France" -> "Lille"). */
|
||||
function cityName(label: string): string {
|
||||
return label.split(',')[0]?.trim() ?? label.trim()
|
||||
}
|
||||
|
||||
/** Format de date que Kombo et CheckMyBus acceptent tous les deux. */
|
||||
function dateISO(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Kombo comprend les noms de ville en clair : pas de mapping de gares a maintenir. */
|
||||
export function buildKomboLink(from: Place, to: Place, date: Date): BookingLink {
|
||||
const f = encodeURIComponent(cityName(from.label))
|
||||
const t = encodeURIComponent(cityName(to.label))
|
||||
const d = dateISO(date)
|
||||
return {
|
||||
url: `https://www.kombo.co/fr/app/outward/1/1/18/${d}/${f}/${t}/0/0/results`,
|
||||
label: 'Reserver sur Kombo',
|
||||
provider: 'Kombo',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckMyBus accepte un fallback avec UUID zeros quand on ne connait pas
|
||||
* son identifiant interne : le nom + les coordonnees suffisent a lancer
|
||||
* la recherche. Le fragment porte tout, y compris la date.
|
||||
*/
|
||||
export function buildCheckMyBusLink(from: Place, to: Place, date: Date): BookingLink {
|
||||
const zeroId = '00000000-0000-0000-0000-000000000000'
|
||||
const encodeStop = (p: Place) =>
|
||||
encodeURIComponent(`${cityName(p.label)}, France`)
|
||||
+ `%24${p.lat}%2C${p.lon}`
|
||||
+ `%24${zeroId}%24${zeroId}%24true%24false`
|
||||
|
||||
const fromSlug = slugify(cityName(from.label))
|
||||
const toSlug = slugify(cityName(to.label))
|
||||
const d = dateISO(date)
|
||||
|
||||
const fragment = [
|
||||
`departureDate=${d}`,
|
||||
`origin=${encodeStop(from)}`,
|
||||
`destination=${encodeStop(to)}`,
|
||||
'sortValue=Relevance',
|
||||
'sortOrder=ascending',
|
||||
'adults=1',
|
||||
].join('&')
|
||||
|
||||
return {
|
||||
url: `https://www.checkmybus.fr/${fromSlug}/${toSlug}?mode=search#${fragment}`,
|
||||
label: 'Chercher sur CheckMyBus',
|
||||
provider: 'CheckMyBus',
|
||||
}
|
||||
}
|
||||
|
||||
/** Chargemap : on ouvre juste la page d'accueil du planificateur. */
|
||||
export function buildChargemapLink(): BookingLink {
|
||||
return {
|
||||
url: 'https://chargemap.com/map',
|
||||
label: 'Ouvrir Chargemap',
|
||||
provider: 'Chargemap',
|
||||
}
|
||||
}
|
||||
|
||||
/** Retourne le lien approprie pour le mode donne, ou null si aucun. */
|
||||
export function buildBookingLink(
|
||||
mode: 'train' | 'bus' | 'electric_car' | 'bike',
|
||||
from: Place,
|
||||
to: Place,
|
||||
date: Date,
|
||||
): BookingLink | null {
|
||||
switch (mode) {
|
||||
case 'train': return buildKomboLink(from, to, date)
|
||||
case 'bus': return buildCheckMyBusLink(from, to, date)
|
||||
case 'electric_car': return buildChargemapLink()
|
||||
case 'bike': return null
|
||||
}
|
||||
}
|
||||
38
shared/emissions.ts
Normal file
38
shared/emissions.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Grammes de CO₂ équivalent par passager et par kilomètre.
|
||||
* Source : ADEME, Base Carbone.
|
||||
*/
|
||||
export const EMISSION_FACTORS = {
|
||||
electric_car: 103,
|
||||
bike: 0,
|
||||
train: 2.4,
|
||||
bus: 113,
|
||||
} as const
|
||||
|
||||
export type TransportMode = keyof typeof EMISSION_FACTORS
|
||||
|
||||
/**
|
||||
* Facteurs par mode Transitous. Chaque tronçon est compté avec son propre
|
||||
* facteur : un TGV et un TER n'ont pas du tout la même empreinte.
|
||||
*/
|
||||
export const LEG_FACTORS: Record<string, number> = {
|
||||
HIGHSPEED_RAIL: 2.4,
|
||||
LONG_DISTANCE: 2.4,
|
||||
NIGHT_RAIL: 2.4,
|
||||
REGIONAL_RAIL: 24.8,
|
||||
REGIONAL_FAST_RAIL: 24.8,
|
||||
RAIL: 24.8,
|
||||
SUBWAY: 4.1,
|
||||
METRO: 4.1,
|
||||
TRAM: 3.6,
|
||||
BUS: 113,
|
||||
COACH: 35.4,
|
||||
WALK: 0,
|
||||
BIKE: 0,
|
||||
}
|
||||
|
||||
/** Mode inconnu : on prend le bus, le plus émetteur, pour ne pas sous-estimer. */
|
||||
export const DEFAULT_LEG_FACTOR = 113
|
||||
|
||||
/** Référence de comparaison : la voiture thermique. */
|
||||
export const PETROL_CAR = 218
|
||||
89
shared/polyline.ts
Normal file
89
shared/polyline.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { Coord } from './types'
|
||||
|
||||
/**
|
||||
* Decode le format « encoded polyline » de Google, utilise par MOTIS.
|
||||
* La precision n'est pas universelle : Google encode a 1e5, MOTIS a 1e7.
|
||||
* Se tromper d'un facteur 100 place le trace a des milliers de kilometres.
|
||||
*/
|
||||
export function decodePolyline(encoded: string, precision = 5): Coord[] {
|
||||
const factor = 10 ** precision
|
||||
const coords: Coord[] = []
|
||||
let index = 0
|
||||
let lat = 0
|
||||
let lon = 0
|
||||
|
||||
while (index < encoded.length) {
|
||||
let result = 0
|
||||
let shift = 0
|
||||
let byte: number
|
||||
|
||||
do {
|
||||
byte = encoded.charCodeAt(index++) - 63
|
||||
result |= (byte & 0x1F) << shift
|
||||
shift += 5
|
||||
} while (byte >= 0x20)
|
||||
lat += result & 1 ? ~(result >> 1) : result >> 1
|
||||
|
||||
result = 0
|
||||
shift = 0
|
||||
do {
|
||||
byte = encoded.charCodeAt(index++) - 63
|
||||
result |= (byte & 0x1F) << shift
|
||||
shift += 5
|
||||
} while (byte >= 0x20)
|
||||
lon += result & 1 ? ~(result >> 1) : result >> 1
|
||||
|
||||
// Ordre GeoJSON : longitude d'abord, comme l'attend MapLibre.
|
||||
coords.push([lon / factor, lat / factor])
|
||||
}
|
||||
|
||||
return coords
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduit le nombre de points d'un trace.
|
||||
* Un aller Lille-Paris peut compter plusieurs milliers de points ; au-dela
|
||||
* d'un millier, l'oeil ne voit plus la difference mais le reseau, si.
|
||||
*/
|
||||
export function thin(coords: Coord[], max = 800): Coord[] {
|
||||
if (coords.length <= max) return coords
|
||||
const step = Math.ceil(coords.length / max)
|
||||
const out = coords.filter((_, i) => i % step === 0)
|
||||
// Le dernier point est le terminus : il ne doit jamais sauter.
|
||||
if (out[out.length - 1] !== coords[coords.length - 1]) out.push(coords[coords.length - 1]!)
|
||||
return out
|
||||
}
|
||||
/**
|
||||
* Renvoie le point situe a un ratio donne de la distance cumulee le long
|
||||
* du trace. midpointAt(coords, 0.5) donne le vrai milieu geometrique, pas
|
||||
* le point d'index milieu qui atterrit dans le vide sur un trajet inegal.
|
||||
* Utilise Haversine simplifie en distance euclidienne : suffisant pour
|
||||
* placer une bulle a l'echelle d'un trajet inter-villes.
|
||||
*/
|
||||
export function midpointAt(coords: Coord[], ratio = 0.5): Coord {
|
||||
if (coords.length === 0) throw new Error('trace vide')
|
||||
if (coords.length === 1) return coords[0]!
|
||||
if (ratio <= 0) return coords[0]!
|
||||
if (ratio >= 1) return coords[coords.length - 1]!
|
||||
|
||||
const distances: number[] = [0]
|
||||
let total = 0
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
const [x1, y1] = coords[i - 1]!
|
||||
const [x2, y2] = coords[i]!
|
||||
total += Math.hypot(x2 - x1, y2 - y1)
|
||||
distances.push(total)
|
||||
}
|
||||
|
||||
const target = total * ratio
|
||||
for (let i = 1; i < distances.length; i++) {
|
||||
if (distances[i]! >= target) {
|
||||
const [x1, y1] = coords[i - 1]!
|
||||
const [x2, y2] = coords[i]!
|
||||
const segLength = distances[i]! - distances[i - 1]!
|
||||
const segRatio = segLength === 0 ? 0 : (target - distances[i - 1]!) / segLength
|
||||
return [x1 + (x2 - x1) * segRatio, y1 + (y2 - y1) * segRatio]
|
||||
}
|
||||
}
|
||||
return coords[coords.length - 1]!
|
||||
}
|
||||
36
shared/types.ts
Normal file
36
shared/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/** Un lieu résolu en coordonnées, tel que renvoyé par le géocodeur. */
|
||||
export interface Place {
|
||||
label: string
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
/** Coordonnée au format GeoJSON : [longitude, latitude]. */
|
||||
export type Coord = [number, number]
|
||||
|
||||
/** Un tronçon d'un trajet en transport en commun, ou un rabattement à pied. */
|
||||
export interface Segment {
|
||||
/** Mode Transitous brut : SUBWAY, HIGHSPEED_RAIL, BUS, WALK... */
|
||||
mode: string
|
||||
/** Nom court affiché sur le véhicule : « M1 », « TGV 7204 », « 12 ». */
|
||||
line: string
|
||||
/** Libellé long, quand il apporte quelque chose : « METRO LIGNE 1 ». */
|
||||
lineLong?: string
|
||||
/** Exploitant commercial : ILEVIA, SNCF, FlixBus. */
|
||||
operator?: string
|
||||
operatorUrl?: string
|
||||
/** Couleur officielle de la ligne, telle que publiée dans le GTFS. */
|
||||
color?: string
|
||||
textColor?: string
|
||||
/** Direction affichée par le véhicule. */
|
||||
headsign?: string
|
||||
from: string
|
||||
to: string
|
||||
departure: string
|
||||
arrival: string
|
||||
durationMin: number
|
||||
/** true si l'horaire provient du temps réel et non du théorique. */
|
||||
realTime: boolean
|
||||
/** Tracé du tronçon, décodé et allégé. */
|
||||
geometry?: Coord[]
|
||||
}
|
||||
65
test/unit/booking.spec.ts
Normal file
65
test/unit/booking.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildBookingLink, buildChargemapLink, buildCheckMyBusLink, buildKomboLink } from '../../shared/booking'
|
||||
import type { Place } from '../../shared/types'
|
||||
|
||||
const lille: Place = { label: 'Lille, France', lat: 50.6292, lon: 3.0573 }
|
||||
const paris: Place = { label: 'Paris, France', lat: 48.8566, lon: 2.3522 }
|
||||
const stEtienne: Place = { label: 'Saint-Étienne, France', lat: 45.4397, lon: 4.3872 }
|
||||
const date = new Date('2026-08-15T00:00:00Z')
|
||||
|
||||
describe('buildKomboLink', () => {
|
||||
it('genere une URL Kombo avec noms de ville en clair', () => {
|
||||
const { url, provider } = buildKomboLink(lille, paris, date)
|
||||
expect(url).toContain('/2026-08-15/Lille/Paris/')
|
||||
expect(url).toContain('/outward/1/1/18/')
|
||||
expect(url).toContain('/results')
|
||||
expect(provider).toBe('Kombo')
|
||||
})
|
||||
|
||||
it('gere les accents dans les noms de ville', () => {
|
||||
const { url } = buildKomboLink(stEtienne, paris, date)
|
||||
// Saint-Etienne conserve son tiret et son E encode
|
||||
expect(url).toContain('Saint-')
|
||||
expect(url).toContain('Paris')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCheckMyBusLink', () => {
|
||||
it('genere une URL CheckMyBus avec slug + coordonnees + UUID zeros', () => {
|
||||
const { url, provider } = buildCheckMyBusLink(lille, paris, date)
|
||||
expect(url).toContain('/lille/paris?')
|
||||
expect(url).toContain('departureDate=2026-08-15')
|
||||
expect(url).toContain('50.6292%2C3.0573')
|
||||
expect(url).toContain('48.8566%2C2.3522')
|
||||
expect(url).toContain('00000000-0000-0000-0000-000000000000')
|
||||
expect(provider).toBe('CheckMyBus')
|
||||
})
|
||||
|
||||
it('slugifie les villes avec accents', () => {
|
||||
const { url } = buildCheckMyBusLink(stEtienne, paris, date)
|
||||
expect(url).toContain('/saint-etienne/paris?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildChargemapLink', () => {
|
||||
it('renvoie la page d accueil du planificateur', () => {
|
||||
const { url, provider } = buildChargemapLink()
|
||||
expect(url).toBe('https://chargemap.com/map')
|
||||
expect(provider).toBe('Chargemap')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBookingLink', () => {
|
||||
it('renvoie Kombo pour train', () => {
|
||||
expect(buildBookingLink('train', lille, paris, date)?.provider).toBe('Kombo')
|
||||
})
|
||||
it('renvoie CheckMyBus pour bus', () => {
|
||||
expect(buildBookingLink('bus', lille, paris, date)?.provider).toBe('CheckMyBus')
|
||||
})
|
||||
it('renvoie Chargemap pour voiture electrique', () => {
|
||||
expect(buildBookingLink('electric_car', lille, paris, date)?.provider).toBe('Chargemap')
|
||||
})
|
||||
it('renvoie null pour velo', () => {
|
||||
expect(buildBookingLink('bike', lille, paris, date)).toBeNull()
|
||||
})
|
||||
})
|
||||
59
test/unit/distances.spec.ts
Normal file
59
test/unit/distances.spec.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { haversineKm, legKm, summarize } from '../../server/utils/transitous'
|
||||
|
||||
const PARIS = { lat: 48.8566, lon: 2.3522 }
|
||||
const LYON = { lat: 45.764, lon: 4.8357 }
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('mesure Paris-Lyon a environ 392 km a vol d oiseau', () => {
|
||||
const km = haversineKm(PARIS, LYON)
|
||||
expect(km).toBeGreaterThan(388)
|
||||
expect(km).toBeLessThan(396)
|
||||
})
|
||||
|
||||
it('renvoie zero entre un point et lui-meme', () => {
|
||||
expect(haversineKm(PARIS, PARIS)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legKm', () => {
|
||||
const base = { mode: 'RAIL', startTime: '', endTime: '', from: PARIS, to: LYON }
|
||||
|
||||
it('utilise la distance fournie quand elle existe', () => {
|
||||
expect(legKm({ ...base, distance: 465000 })).toBe(465)
|
||||
})
|
||||
|
||||
it('retombe sur le calcul geometrique quand la distance manque', () => {
|
||||
expect(legKm(base)).toBeCloseTo(haversineKm(PARIS, LYON), 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarize', () => {
|
||||
const leg = (mode: string, distance: number) => ({
|
||||
mode,
|
||||
distance,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
from: PARIS,
|
||||
to: PARIS,
|
||||
})
|
||||
|
||||
it('applique a chaque troncon son propre facteur d emission', () => {
|
||||
// 10 km de metro a 4,1 g + 400 km de TGV a 2,4 g
|
||||
const r = summarize({ duration: 7200, transfers: 1, legs: [leg('SUBWAY', 10000), leg('HIGHSPEED_RAIL', 400000)] }, 'train')
|
||||
expect(r.co2Grams).toBe(1001)
|
||||
expect(r.distanceKm).toBe(410)
|
||||
expect(r.durationMin).toBe(120)
|
||||
})
|
||||
|
||||
it('ne compte pas la marche dans les emissions', () => {
|
||||
const r = summarize({ duration: 600, transfers: 0, legs: [leg('WALK', 2000)] }, 'bus')
|
||||
expect(r.co2Grams).toBe(0)
|
||||
})
|
||||
|
||||
it('prend le facteur le plus penalisant pour un mode inconnu', () => {
|
||||
// Mieux vaut surestimer que de laisser croire a un trajet propre.
|
||||
const r = summarize({ duration: 600, transfers: 0, legs: [leg('TELEPORTATION', 10000)] }, 'bus')
|
||||
expect(r.co2Grams).toBe(1130)
|
||||
})
|
||||
})
|
||||
42
test/unit/estimate.spec.ts
Normal file
42
test/unit/estimate.spec.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { estimateTrainTrip } from '../../server/utils/estimate'
|
||||
|
||||
describe('estimateTrainTrip', () => {
|
||||
const grenoble = { lat: 45.1885, lon: 5.7245 }
|
||||
const marseille = { lat: 43.2965, lon: 5.3698 }
|
||||
const lille = { lat: 50.6292, lon: 3.0573 }
|
||||
const paris = { lat: 48.8566, lon: 2.3522 }
|
||||
|
||||
it('renvoie un objet marque comme estime', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.estimated).toBe(true)
|
||||
expect(trip.mode).toBe('train')
|
||||
})
|
||||
|
||||
it('estime Grenoble-Marseille (vol oiseau 216 km, ferroviaire ~260 km)', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.distanceKm).toBeGreaterThan(230)
|
||||
expect(trip.distanceKm).toBeLessThan(290)
|
||||
// A 90 km/h : entre 2h30 et 3h20 environ
|
||||
expect(trip.durationMin).toBeGreaterThan(150)
|
||||
expect(trip.durationMin).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('estime Lille-Paris (vol oiseau 203 km, ferroviaire ~245 km)', () => {
|
||||
const trip = estimateTrainTrip(lille, paris)
|
||||
expect(trip.distanceKm).toBeGreaterThan(220)
|
||||
expect(trip.distanceKm).toBeLessThan(280)
|
||||
})
|
||||
|
||||
it('renvoie une geometrie a deux points', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.geometry).toHaveLength(2)
|
||||
expect(trip.geometry[0]).toEqual([grenoble.lon, grenoble.lat])
|
||||
expect(trip.geometry[1]).toEqual([marseille.lon, marseille.lat])
|
||||
})
|
||||
|
||||
it('calcule le CO2 avec le facteur ADEME train (2.4 g/km)', () => {
|
||||
const trip = estimateTrainTrip(grenoble, marseille)
|
||||
expect(trip.co2Grams).toBeCloseTo(trip.distanceKm * 2.4, 0)
|
||||
})
|
||||
})
|
||||
28
test/unit/geocoding.spec.ts
Normal file
28
test/unit/geocoding.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { dedupe } from '../../server/utils/geocoding'
|
||||
|
||||
const place = (label: string, lat: number, lon: number) => ({ label, lat, lon })
|
||||
|
||||
describe('dedupe', () => {
|
||||
it('elimine deux entrees au meme libelle', () => {
|
||||
expect(dedupe([place('Lille', 50.63, 3.05), place('lille', 47.1, 2.2)])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('elimine deux entrees a la meme position malgre des libelles differents', () => {
|
||||
const r = dedupe([
|
||||
place('Bruxelles, Belgique', 50.846, 4.352),
|
||||
place('Bruxelles-Capitale, Belgique', 50.848, 4.351),
|
||||
])
|
||||
expect(r).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('conserve deux villes homonymes reellement distinctes', () => {
|
||||
const r = dedupe([place('Lille, France', 50.63, 3.05), place('Lille, Belgique', 50.72, 3.20)])
|
||||
expect(r).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserve l ordre d entree', () => {
|
||||
const r = dedupe([place('A', 1, 1), place('B', 2, 2), place('C', 3, 3)])
|
||||
expect(r.map(p => p.label)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
})
|
||||
41
test/unit/midpoint.spec.ts
Normal file
41
test/unit/midpoint.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { midpointAt } from '../../shared/polyline'
|
||||
import type { Coord } from '../../shared/types'
|
||||
|
||||
describe('midpointAt', () => {
|
||||
it('renvoie le point unique quand un seul est fourni', () => {
|
||||
expect(midpointAt([[1, 2]], 0.5)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('renvoie le milieu geometrique sur une ligne droite reguliere', () => {
|
||||
const coords: Coord[] = [[0, 0], [10, 0]]
|
||||
expect(midpointAt(coords, 0.5)).toEqual([5, 0])
|
||||
})
|
||||
|
||||
it('renvoie le vrai milieu de distance sur un trace inegal', () => {
|
||||
// Trajet : 0 -> 10 (long), puis 10 -> 11 (court)
|
||||
// Distance totale = 11, milieu a 5.5, donc sur le premier segment
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [11, 0]]
|
||||
const mid = midpointAt(coords, 0.5)
|
||||
expect(mid[0]).toBeCloseTo(5.5, 5)
|
||||
expect(mid[1]).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
it('renvoie le point de debut avec ratio 0', () => {
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [20, 5]]
|
||||
expect(midpointAt(coords, 0)).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('renvoie le point de fin avec ratio 1', () => {
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [20, 5]]
|
||||
expect(midpointAt(coords, 1)).toEqual([20, 5])
|
||||
})
|
||||
|
||||
it('gere un trace en forme de coude', () => {
|
||||
// 0,0 -> 10,0 -> 10,10 : total = 20, milieu = 10 = fin du premier segment
|
||||
const coords: Coord[] = [[0, 0], [10, 0], [10, 10]]
|
||||
const mid = midpointAt(coords, 0.5)
|
||||
expect(mid[0]).toBeCloseTo(10, 5)
|
||||
expect(mid[1]).toBeCloseTo(0, 5)
|
||||
})
|
||||
})
|
||||
43
test/unit/polyline.spec.ts
Normal file
43
test/unit/polyline.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { decodePolyline, thin } from '#shared/polyline'
|
||||
|
||||
describe('decodePolyline', () => {
|
||||
it('decode l exemple de reference de Google a la precision 5', () => {
|
||||
// Exemple officiel : trois points en Californie.
|
||||
const r = decodePolyline('_p~iF~ps|U_ulLnnqC_mqNvxq`@', 5)
|
||||
expect(r).toHaveLength(3)
|
||||
// Ordre GeoJSON : longitude d'abord.
|
||||
expect(r[0]![1]).toBeCloseTo(38.5, 4)
|
||||
expect(r[0]![0]).toBeCloseTo(-120.2, 4)
|
||||
})
|
||||
|
||||
it('place le trace ailleurs si la precision est fausse', () => {
|
||||
// C'est tout l'enjeu : MOTIS encode a 1e7, Google a 1e5.
|
||||
const juste = decodePolyline('_p~iF~ps|U', 5)
|
||||
const faux = decodePolyline('_p~iF~ps|U', 7)
|
||||
expect(faux[0]![1]).not.toBeCloseTo(juste[0]![1], 2)
|
||||
expect(faux[0]![1]).toBeCloseTo(juste[0]![1] / 100, 4)
|
||||
})
|
||||
|
||||
it('renvoie un tableau vide sur une chaine vide', () => {
|
||||
expect(decodePolyline('', 7)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thin', () => {
|
||||
const trace = Array.from({ length: 1000 }, (_, i) => [i, i] as [number, number])
|
||||
|
||||
it('ne touche pas a un trace deja assez court', () => {
|
||||
const court = trace.slice(0, 50)
|
||||
expect(thin(court, 800)).toBe(court)
|
||||
})
|
||||
|
||||
it('ramene un trace long sous la limite demandee', () => {
|
||||
expect(thin(trace, 100).length).toBeLessThanOrEqual(101)
|
||||
})
|
||||
|
||||
it('conserve toujours le terminus', () => {
|
||||
const r = thin(trace, 100)
|
||||
expect(r[r.length - 1]).toEqual(trace[trace.length - 1])
|
||||
})
|
||||
})
|
||||
106
test/unit/publicTransport.spec.ts
Normal file
106
test/unit/publicTransport.spec.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { publicTransport } from '../../server/utils/transitous'
|
||||
|
||||
const LILLE = { lat: 50.6292, lon: 3.0573 }
|
||||
const BRUXELLES = { lat: 50.8503, lon: 4.3517 }
|
||||
|
||||
const leg = (mode: string, distance: number, minutes: number) => ({
|
||||
mode,
|
||||
distance,
|
||||
duration: minutes * 60,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
from: LILLE,
|
||||
to: BRUXELLES,
|
||||
})
|
||||
|
||||
const journey = (legs: ReturnType<typeof leg>[], minutes: number, transfers = 0) => ({
|
||||
duration: minutes * 60,
|
||||
transfers,
|
||||
legs,
|
||||
})
|
||||
|
||||
/**
|
||||
* Le vrai code passe deux requetes distinctes, une par famille de mode.
|
||||
* Le faux doit faire la meme distinction, sinon il renvoie un trajet en car
|
||||
* a la requete ferroviaire et le test valide un comportement impossible.
|
||||
*/
|
||||
function mockApi(byFamily: { rail?: unknown[], urban?: unknown[] }) {
|
||||
const spy = vi.fn().mockImplementation((url: string) => {
|
||||
const rail = url.includes('HIGHSPEED_RAIL')
|
||||
return Promise.resolve({ itineraries: (rail ? byFamily.rail : byFamily.urban) ?? [] })
|
||||
})
|
||||
vi.stubGlobal('$fetch', spy)
|
||||
return spy
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('publicTransport', () => {
|
||||
it('interroge chaque famille de mode separement', async () => {
|
||||
const spy = mockApi({})
|
||||
await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
const urls = spy.mock.calls.map(c => c[0] as string)
|
||||
expect(urls.some(u => u.includes('HIGHSPEED_RAIL'))).toBe(true)
|
||||
expect(urls.some(u => u.includes('COACH') && !u.includes('HIGHSPEED_RAIL'))).toBe(true)
|
||||
})
|
||||
|
||||
it('separe le meilleur trajet ferroviaire du meilleur trajet urbain', async () => {
|
||||
mockApi({
|
||||
rail: [journey([leg('HIGHSPEED_RAIL', 100000, 90)], 90, 1)],
|
||||
urban: [journey([leg('BUS', 95000, 180)], 180, 3)],
|
||||
})
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
// 100 km de TGV a 2,4 g contre 95 km de bus a 113 g.
|
||||
expect(r.train?.co2Grams).toBe(240)
|
||||
expect(r.bus?.co2Grams).toBe(10735)
|
||||
expect(r.train?.transfers).toBe(1)
|
||||
})
|
||||
|
||||
it('retient le plus rapide quand plusieurs trajets ferroviaires existent', async () => {
|
||||
mockApi({
|
||||
rail: [
|
||||
journey([leg('REGIONAL_RAIL', 100000, 180)], 180, 2),
|
||||
journey([leg('HIGHSPEED_RAIL', 100000, 90)], 90, 0),
|
||||
],
|
||||
})
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(r.train?.durationMin).toBe(90)
|
||||
expect(r.train?.transfers).toBe(0)
|
||||
})
|
||||
|
||||
it('renvoie null pour un mode absent plutot que d inventer un trajet', async () => {
|
||||
mockApi({ urban: [journey([leg('COACH', 50000, 60)], 60)] })
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(r.train).toBeNull()
|
||||
expect(r.bus).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ecarte un itineraire entierement pietonnier', async () => {
|
||||
// MOTIS propose parfois de marcher faute de mieux : ce n'est pas
|
||||
// un trajet en transport en commun et il ne doit pas s'afficher.
|
||||
mockApi({ rail: [journey([leg('WALK', 3000, 40)], 40)] })
|
||||
|
||||
const r = await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
expect(r.train).toBeNull()
|
||||
})
|
||||
|
||||
it('s identifie aupres de Transitous comme leur politique d usage l exige', async () => {
|
||||
const spy = mockApi({})
|
||||
|
||||
await publicTransport(LILLE, BRUXELLES, new Date())
|
||||
|
||||
const [url, options] = spy.mock.calls[0]
|
||||
expect(url).toContain('fromPlace=50.6292,3.0573')
|
||||
expect(options.headers['User-Agent']).toContain('lecheminvert.tech')
|
||||
})
|
||||
})
|
||||
69
test/unit/transitous.spec.ts
Normal file
69
test/unit/transitous.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { dominant, family, legMin } from '../../server/utils/transitous'
|
||||
|
||||
const leg = (mode: string, minutes: number) => ({
|
||||
mode,
|
||||
duration: minutes * 60,
|
||||
startTime: '2026-07-29T08:00:00Z',
|
||||
endTime: '2026-07-29T08:00:00Z',
|
||||
from: { lat: 0, lon: 0 },
|
||||
to: { lat: 0, lon: 0 },
|
||||
})
|
||||
|
||||
const journey = (legs: ReturnType<typeof leg>[]) => ({ duration: 0, transfers: 0, legs })
|
||||
|
||||
describe('family', () => {
|
||||
it('classe le TGV et le TER dans la meme famille ferroviaire', () => {
|
||||
expect(family('HIGHSPEED_RAIL')).toBe('rail')
|
||||
expect(family('REGIONAL_RAIL')).toBe('rail')
|
||||
})
|
||||
|
||||
it('classe metro, tram et bus comme transport urbain', () => {
|
||||
expect(family('SUBWAY')).toBe('urban')
|
||||
expect(family('TRAM')).toBe('urban')
|
||||
expect(family('BUS')).toBe('urban')
|
||||
})
|
||||
|
||||
it('ne compte pas la marche comme un transport', () => {
|
||||
expect(family('WALK')).toBe('walk')
|
||||
})
|
||||
|
||||
it('traite un mode inconnu comme de la marche plutot que de planter', () => {
|
||||
expect(family('TELEPORTATION')).toBe('walk')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legMin', () => {
|
||||
it('convertit la duree en minutes', () => {
|
||||
expect(legMin(leg('BUS', 12))).toBe(12)
|
||||
})
|
||||
|
||||
it('retombe sur les horodatages quand la duree manque', () => {
|
||||
const l = {
|
||||
mode: 'BUS',
|
||||
startTime: '2026-07-29T08:00:00Z',
|
||||
endTime: '2026-07-29T08:45:00Z',
|
||||
from: { lat: 0, lon: 0 },
|
||||
to: { lat: 0, lon: 0 },
|
||||
}
|
||||
expect(legMin(l)).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dominant', () => {
|
||||
it('classe en ferroviaire un trajet TGV precede d un bus de rabattement', () => {
|
||||
expect(dominant(journey([leg('BUS', 10), leg('HIGHSPEED_RAIL', 100)]))).toBe('rail')
|
||||
})
|
||||
|
||||
it('classe en urbain un trajet ou le bus domine largement', () => {
|
||||
expect(dominant(journey([leg('REGIONAL_RAIL', 5), leg('BUS', 90)]))).toBe('urban')
|
||||
})
|
||||
|
||||
it('ignore la marche dans la ponderation', () => {
|
||||
expect(dominant(journey([leg('WALK', 200), leg('TRAM', 10)]))).toBe('urban')
|
||||
})
|
||||
|
||||
it('renvoie walk quand il n y a aucun transport en commun', () => {
|
||||
expect(dominant(journey([leg('WALK', 30)]))).toBe('walk')
|
||||
})
|
||||
})
|
||||
138
tests/app.spec.ts
Normal file
138
tests/app.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { expect, test } from '@nuxt/test-utils/playwright'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
test.describe('API de geocodage', () => {
|
||||
test('resout une ville en coordonnees', async ({ request }) => {
|
||||
const res = await request.get('/api/geocode?q=lille')
|
||||
expect(res.ok()).toBeTruthy()
|
||||
|
||||
const places = await res.json()
|
||||
expect(places.length).toBeGreaterThan(0)
|
||||
expect(places[0].lat).toBeCloseTo(50.63, 1)
|
||||
expect(places[0].lon).toBeCloseTo(3.05, 1)
|
||||
})
|
||||
|
||||
test('trouve une ville hors de France', async ({ request }) => {
|
||||
const res = await request.get('/api/geocode?q=Bruxelles')
|
||||
const places = await res.json()
|
||||
// La BAN ignore la Belgique : sans Photon, on retomberait sur un hameau du Loiret.
|
||||
const belge = places.find((p: { label: string }) => p.label.includes('Belgique'))
|
||||
expect(belge).toBeDefined()
|
||||
expect(belge.lat).toBeCloseTo(50.85, 1)
|
||||
})
|
||||
|
||||
test('renvoie un tableau vide sur une saisie trop courte', async ({ request }) => {
|
||||
const res = await request.get('/api/geocode?q=li')
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comparateur', () => {
|
||||
const bouton = (page: Page) => page.getByRole('button', { name: 'Comparer', exact: true })
|
||||
|
||||
/** Remplit un champ puis choisit une proposition dans SA propre liste. */
|
||||
async function choisir(page: Page, id: string, champ: string, saisie: string, index = 0) {
|
||||
await page.getByLabel(champ).fill(saisie)
|
||||
await page.locator(`#${id}-list [role="option"]`).nth(index).click()
|
||||
}
|
||||
|
||||
async function comparer(page: Page) {
|
||||
await choisir(page, 'from', 'Départ', 'Lille', 0)
|
||||
await choisir(page, 'to', 'Arrivée', 'Bruxelles', 1)
|
||||
await bouton(page).click()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// On simule les appels sortants : un test e2e ne doit dependre
|
||||
// ni du quota OpenRouteService ni de la disponibilite de Transitous.
|
||||
await page.route('**/api/geocode**', route => route.fulfill({
|
||||
json: [
|
||||
{ label: 'Lille, France', lat: 50.6292, lon: 3.0573 },
|
||||
{ label: 'Bruxelles, Belgique', lat: 50.8503, lon: 4.3517 },
|
||||
],
|
||||
}))
|
||||
|
||||
await page.route('**/api/compare', route => route.fulfill({
|
||||
json: [
|
||||
{ mode: 'bike', distanceKm: 116, durationMin: 402, co2Grams: 0 },
|
||||
{ mode: 'train', distanceKm: 97, durationMin: 136, co2Grams: 2339, transfers: 2 },
|
||||
{ mode: 'bus', distanceKm: 97, durationMin: 137, co2Grams: 3321, transfers: 2 },
|
||||
{ mode: 'electric_car', distanceKm: 118, durationMin: 86, co2Grams: 12176 },
|
||||
],
|
||||
}))
|
||||
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('interdit le calcul tant qu aucun lieu n est choisi', async ({ page }) => {
|
||||
await expect(bouton(page)).toBeDisabled()
|
||||
})
|
||||
|
||||
test('ferme la liste apres selection sans la rouvrir', async ({ page }) => {
|
||||
await choisir(page, 'from', 'Départ', 'Lille', 0)
|
||||
// Le watch se redeclenchait sur la valeur ecrite par select() et rouvrait
|
||||
// la liste : le champ suivant devenait inatteignable a la souris.
|
||||
await page.waitForTimeout(600)
|
||||
await expect(page.locator('#from-list')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('reflete le modele quand il change de l exterieur', async ({ page }) => {
|
||||
await choisir(page, 'from', 'Départ', 'Lille', 0)
|
||||
await choisir(page, 'to', 'Arrivée', 'Bruxelles', 1)
|
||||
|
||||
// Sans watch sur le modele, l'inversion laissait les champs inchanges
|
||||
// et l'affichage mentait sur ce qui allait etre calcule.
|
||||
await page.getByRole('button', { name: 'Inverser' }).click()
|
||||
await expect(page.getByLabel('Départ')).toHaveValue('Bruxelles, Belgique')
|
||||
await expect(page.getByLabel('Arrivée')).toHaveValue('Lille, France')
|
||||
})
|
||||
|
||||
test('interdit le calcul si la saisie ne correspond plus au lieu choisi', async ({ page }) => {
|
||||
await choisir(page, 'from', 'Départ', 'Lille', 0)
|
||||
await choisir(page, 'to', 'Arrivée', 'Bruxelles', 1)
|
||||
await expect(bouton(page)).toBeEnabled()
|
||||
|
||||
await page.getByLabel('Arrivée').fill('Bruxel')
|
||||
await expect(bouton(page)).toBeDisabled()
|
||||
})
|
||||
|
||||
test('lance un calcul depuis un trajet d exemple', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Lille → Paris' }).click()
|
||||
await expect(page.getByRole('heading', { level: 3 })).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('affiche les quatre modes du plus sobre au plus emetteur', async ({ page }) => {
|
||||
await comparer(page)
|
||||
|
||||
// Le titre porte aussi le chevron et la duree : on cherche le libelle
|
||||
// dans son contenu plutot qu'une egalite stricte.
|
||||
const titles = page.getByRole('heading', { level: 3 })
|
||||
await expect(titles).toHaveCount(4)
|
||||
await expect(titles.nth(0)).toContainText('Vélo')
|
||||
await expect(titles.nth(3)).toContainText('Voiture électrique')
|
||||
})
|
||||
|
||||
test('rappelle les lieux reellement compares', async ({ page }) => {
|
||||
await comparer(page)
|
||||
const rappel = page.getByRole('heading', { level: 2 })
|
||||
await expect(rappel).toContainText('Lille, France')
|
||||
await expect(rappel).toContainText('Bruxelles, Belgique')
|
||||
})
|
||||
|
||||
test('affiche les correspondances des transports en commun', async ({ page }) => {
|
||||
await comparer(page)
|
||||
await expect(page.getByText('2 corresp.').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('convertit les emissions en kilogrammes au-dela de mille grammes', async ({ page }) => {
|
||||
await comparer(page)
|
||||
await expect(page.getByText('12.2 kg CO₂e')).toBeVisible()
|
||||
await expect(page.getByText('0 g CO₂e')).toBeVisible()
|
||||
})
|
||||
|
||||
test('previent l utilisateur quand le calcul echoue', async ({ page }) => {
|
||||
await page.route('**/api/compare', route => route.fulfill({ status: 500, json: {} }))
|
||||
await comparer(page)
|
||||
await expect(page.getByRole('alert')).toBeVisible()
|
||||
})
|
||||
})
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
vitest.config.ts
Normal file
39
vitest.config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { defineVitestProject } from '@nuxt/test-utils/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
// Les tests unitaires tournent hors Nuxt : on redeclare l'alias #shared.
|
||||
resolve: {
|
||||
alias: { '#shared': fileURLToPath(new URL('./shared', import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
name: 'unit',
|
||||
include: ['test/unit/*.{test,spec}.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
await defineVitestProject({
|
||||
test: {
|
||||
name: 'nuxt',
|
||||
include: ['test/nuxt/*.{test,spec}.ts'],
|
||||
environment: 'nuxt',
|
||||
environmentOptions: {
|
||||
nuxt: {
|
||||
rootDir: fileURLToPath(new URL('.', import.meta.url)),
|
||||
domEnvironment: 'happy-dom',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
include: ['server/**', 'shared/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue