diff --git a/README.md b/README.md
index 92d98258..2da189dc 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,7 @@ Les fonctionnalités correspondent aux outils MCP documentés dans [`docs/mcp-to
| Récupérer un objet par identifiant | `gpf_get_feature_by_id` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Charger une commune précise |
| Télécharger le résultat d'une interrogation de couche GPF | `gpf_get_features_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) + [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier un résultat |
| Télécharger un objet par identifiant | `gpf_get_feature_by_id_layer` | [WFS](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/diffusion/wfs/) | Cartographier un objet |
+| Télécharger une isochrone ou une isodistance | `gpf_isoline_layer` | [isochrone](https://cartes.gouv.fr/aide/fr/guides-utilisateur/utiliser-les-services-de-la-geoplateforme/calcul-isochrone-isodistance/) | Cartographier une desserte |
## Architecture en bref
diff --git a/docs/config.md b/docs/config.md
index 0a00ad26..c01e37f1 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -28,12 +28,12 @@
| `PROXY_ENDPOINT` | Chemin exposé par le proxy geodata. | `/api/v1/proxy` |
| `PROXY_PUBLIC_BASE_URL` | URL de base publiquement joignable du proxy, utilisée pour construire la `data_url` absolue transmise à Carto. Derrière un reverse-proxy, elle diffère de l'adresse d'écoute ; en développement local, c'est typiquement `http://localhost:3002`. Requise avec `PROXY_URL_SECRET` pour activer les tools `*_layer`. | Aucune |
| `GPF_WFS_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le WFS, distincte de `GPF_WFS_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `10` |
-| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `travel_time`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` |
-| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. | `10` |
+| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `isoline` et `gpf_isochrone_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` |
+| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `isoline`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isochrone_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |
## Génération de `PROXY_URL_SECRET`
-Pour produire des URLs opaques d'affichage cartographique (tools `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer).
+Pour produire des URLs opaques d'affichage cartographique (tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`), geocontext chiffre les paramètres de requête avec une clé symétrique AES-256, fournie via `PROXY_URL_SECRET`. La même clé est utilisée par le MCP (pour signer) et par le proxy geodata (pour déchiffrer).
La clé doit être une valeur aléatoire de **32 octets encodée en hexadécimal** (soit 64 caractères `0-9a-f`). Générez-la avec :
diff --git a/docs/dev.md b/docs/dev.md
index 3c80d322..aa14470f 100644
--- a/docs/dev.md
+++ b/docs/dev.md
@@ -60,7 +60,7 @@ Avec certains clients MCP, vous serez amené à éditer un fichier JSON. Par exe
## Activer les tools cartographiques en local
-Les tools `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker.
+Les tools `gpf_isoline_layer`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer` renvoient une `data_url` opaque, servie par le **proxy geodata**, un processus séparé du serveur MCP. Ces tools sont listés dans tous les transports mais échouent tant qu'aucun proxy geodata joignable n'est configuré. Comme le proxy geodata est **indépendant du transport**, on peut les activer en local (**même en `stdio`**) en lançant les deux composants côte à côte, sans Docker.
Il faut une clé partagée (`PROXY_URL_SECRET`) entre les deux processus, et pointer le MCP vers le proxy geodata local via `PROXY_PUBLIC_BASE_URL`.
diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index 2163ef58..998e2bfb 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -35,7 +35,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool :
| `readOnlyHint` | oui | Le tool consulte des données sans modifier d'état côté serveur. |
| `destructiveHint` | non | Le tool n'est pas signalé comme destructif. |
| `idempotentHint` | oui | Répéter le même appel ne déclenche pas d'effet de bord supplémentaire attendu. |
-| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer` et `gpf_get_feature_by_id_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. |
+| `openWorldHint` | oui (non pour `gpf_search_types`, `gpf_describe_type`, `gpf_get_features_layer`, `gpf_get_feature_by_id_layer` et `gpf_isoline_layer`) | Le tool interroge des sources externes ou ouvertes, dont le contenu peut évoluer. |
## Liste des tools
@@ -52,6 +52,7 @@ Annotations MCP exposées dans la définition `tools/list` de chaque tool :
- [`gpf_count_features`](#gpf_count_features)
- [`gpf_get_feature_by_id`](#gpf_get_feature_by_id)
- [`gpf_get_feature_by_id_layer`](#gpf_get_feature_by_id_layer)
+- [`gpf_isoline_layer`](#gpf_isoline_layer)
- [`distance`](#distance)
## `geocode`
@@ -1083,14 +1084,15 @@ Lecture d’objets GPF
```
Interroge un type GPF et renvoie des résultats structurés (propriétés attributaires ; les géométries ne sont pas incluses). Pour obtenir une couche cartographiable, utiliser `gpf_get_features_layer`.
-Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial.
+Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `isoline_filter`) pour le spatial.
Utiliser `spatial_extras` pour obtenir des mesures calculées sur la géométrie (`centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`). Elles portent uniquement sur les objets renvoyés.
Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`.
Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`.
Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.
Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.
Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.
-Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }` pour les objets atteignables en 15 minutes à pied depuis ce point.
+Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.
+Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "car", cost_type: "distance", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.
⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.
**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**
Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_describe_type` **provoquera une erreur.**
@@ -1101,14 +1103,14 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. |
-| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. |
+| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux. |
| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. |
| `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. |
+| `isoline_filter` | object | non | Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux. |
| `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. |
| `order_by` | array | non | Liste ordonnée des critères de tri. |
| `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. |
| `spatial_extras` | array | non | Éléments calculés depuis la géométrie à renvoyer pour chaque objet. Peut inclure `centroid`, `bbox`, `length`, `area`, `distance_to_filter_center` et `intersection_area`, aucun par défaut.
`centroid` est le centroïde (moyenne arithmétique des sommets) de la géométrie. Il peut tomber hors d'une géométrie concave : un `intersects_point_filter` sur ce point peut alors ne renvoyer ni l'objet, ni ce qui le contient.
`bbox` est la boîte englobante de la géométrie : `[ouest, sud, est, nord]` en WGS84 `lon/lat`, dans l'ordre des champs `west`, `south`, `east` et `north` de `bbox_filter`.
`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).
`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).
`distance_to_filter_center` est la distance (en m) entre le centre du filtre spatial et le point le plus proche de l'objet renvoyé, `0` si l'objet contient ce centre. Ce centre est le point de `dwithin_point_filter`, le point de départ de `travel_time_filter`, le centre de la boîte de `bbox_filter` et le centroïde (moyenne des sommets) de l'objet de référence de `intersects_feature_filter`.
`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone ou objet de référence surfacique). Elle vaut `null` si l'objet renvoyé n'a pas de partie surfacique, et `0` si l'objet ne recouvre pas le filtre.
`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.
Les `spatial_extras` sont calculés après la requête, sur les seuls objets renvoyés : ils ne sont utilisables ni dans `where` ni dans `order_by`. Pour un classement (les N plus grands, les N plus proches) ou une somme, vérifier que `numberReturned` est égal à `numberMatched`, sinon augmenter `limit` ou restreindre le filtre spatial. Pour les N plus proches d'un point, utiliser `dwithin_point_filter` avec `distance_to_filter_center`, trier sur cette distance et élargir `distance_m` s'il y a moins de N objets.
Si l'élément à calculer est incompatible avec la géométrie (exemple : bbox d'un point, aire d'une géométrie linéaire) et que le type de la géométrie est connu à l'avance, une erreur indiquera comment corriger la requête.
Sinon, un élément qui n'est pas calculable pour un objet (géométrie absente ou vide, aucune partie de la dimension requise) vaut `null`. Une valeur numérique, `0` compris, signifie que le calcul a bien eu lieu. Valeur par défaut : []. |
-| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. |
| `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. |
| `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. |
@@ -1269,7 +1271,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
"distance_m"
],
"additionalProperties": false,
- "description": "Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux."
+ "description": "Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux."
},
"intersects_feature_filter": {
"type": "object",
@@ -1292,7 +1294,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
"additionalProperties": false,
"description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux."
},
- "travel_time_filter": {
+ "isoline_filter": {
"type": "object",
"properties": {
"lon": {
@@ -1307,29 +1309,37 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
"maximum": 90,
"description": "Latitude du point de départ en WGS84 `lon/lat`."
},
- "minutes": {
- "type": "number",
- "exclusiveMinimum": 0,
- "maximum": 120,
- "description": "Temps de trajet maximal en minutes. Maximum : 120."
- },
"profile": {
"type": "string",
"enum": [
"car",
"pedestrian"
],
- "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
+ "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
+ },
+ "cost_type": {
+ "type": "string",
+ "enum": [
+ "time",
+ "distance"
+ ],
+ "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."
+ },
+ "cost_value": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 120), et en mètres si `cost_type = \"distance\"` (maximum : 50000)."
}
},
"required": [
"lon",
"lat",
- "minutes",
- "profile"
+ "profile",
+ "cost_type",
+ "cost_value"
],
"additionalProperties": false,
- "description": "Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux."
+ "description": "Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux."
},
"limit": {
"type": "integer",
@@ -1418,7 +1428,7 @@ Couche cartographiable d’objets GPF
```
Interroge un type GPF et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec les géométries complètes.
À utiliser dès qu'il faut **afficher / cartographier** des objets GPF. Pour des attributs sans géométrie, utiliser `gpf_get_features`.
-Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial.
+Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `isoline_filter`) pour le spatial.
**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Les noms de propriétés ne peuvent pas être devinés.
```
@@ -1427,13 +1437,13 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. |
-| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. |
+| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux. |
| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. |
| `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. |
+| `isoline_filter` | object | non | Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux. |
| `limit` | integer | non | Nombre maximum d'objets à cartographier. Valeur par défaut : 5000 (plafond du service). Réduire pour alléger la carte. Maximum : 5000. Une requête produisant plus de 5000 objets sera tronquée. Valeur par défaut : 5000. |
| `order_by` | array | non | Liste ordonnée des critères de tri. |
| `select` | array | non | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. |
-| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. |
| `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. |
| `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. |
@@ -1594,7 +1604,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
"distance_m"
],
"additionalProperties": false,
- "description": "Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux."
+ "description": "Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux."
},
"intersects_feature_filter": {
"type": "object",
@@ -1617,7 +1627,7 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
"additionalProperties": false,
"description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux."
},
- "travel_time_filter": {
+ "isoline_filter": {
"type": "object",
"properties": {
"lon": {
@@ -1632,29 +1642,37 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
"maximum": 90,
"description": "Latitude du point de départ en WGS84 `lon/lat`."
},
- "minutes": {
- "type": "number",
- "exclusiveMinimum": 0,
- "maximum": 120,
- "description": "Temps de trajet maximal en minutes. Maximum : 120."
- },
"profile": {
"type": "string",
"enum": [
"car",
"pedestrian"
],
- "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
+ "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
+ },
+ "cost_type": {
+ "type": "string",
+ "enum": [
+ "time",
+ "distance"
+ ],
+ "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."
+ },
+ "cost_value": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 120), et en mètres si `cost_type = \"distance\"` (maximum : 50000)."
}
},
"required": [
"lon",
"lat",
- "minutes",
- "profile"
+ "profile",
+ "cost_type",
+ "cost_value"
],
"additionalProperties": false,
- "description": "Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux."
+ "description": "Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux."
},
"order_by": {
"type": "array",
@@ -1749,13 +1767,14 @@ Décompte d’objets GPF
```
Interroge un type GPF et renvoie le nombre de résultats obtenus.
-Utiliser `where` pour filtrer et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial.
+Utiliser `where` pour filtrer et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `isoline_filter`) pour le spatial.
Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`.
Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`.
Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.
Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.
Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.
-Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }` pour les objets atteignables en 15 minutes à pied depuis ce point.
+Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.
+Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: "car", cost_type: "distance", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.
⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.
**OBLIGATOIRE dès que `where` est utilisé : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Un comptage par `typename` et filtre spatial seul (sans `where`) ne référence aucun nom de propriété et ne nécessite pas cet appel préalable.
Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute clause `where` sans appel préalable à `gpf_describe_type` **provoquera une erreur.**
@@ -1766,10 +1785,10 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. |
-| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. |
+| `dwithin_point_filter` | object | non | Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux. |
| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. |
| `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. |
-| `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. |
+| `isoline_filter` | object | non | Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux. |
| `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. |
| `where` | array | non | Clauses de filtre attributaire, combinées avec `AND`. |
@@ -1921,7 +1940,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
"distance_m"
],
"additionalProperties": false,
- "description": "Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux."
+ "description": "Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux."
},
"intersects_feature_filter": {
"type": "object",
@@ -1944,7 +1963,7 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
"additionalProperties": false,
"description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux."
},
- "travel_time_filter": {
+ "isoline_filter": {
"type": "object",
"properties": {
"lon": {
@@ -1959,29 +1978,37 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
"maximum": 90,
"description": "Latitude du point de départ en WGS84 `lon/lat`."
},
- "minutes": {
- "type": "number",
- "exclusiveMinimum": 0,
- "maximum": 120,
- "description": "Temps de trajet maximal en minutes. Maximum : 120."
- },
"profile": {
"type": "string",
"enum": [
"car",
"pedestrian"
],
- "description": "Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."
+ "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
+ },
+ "cost_type": {
+ "type": "string",
+ "enum": [
+ "time",
+ "distance"
+ ],
+ "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."
+ },
+ "cost_value": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 120), et en mètres si `cost_type = \"distance\"` (maximum : 50000)."
}
},
"required": [
"lon",
"lat",
- "minutes",
- "profile"
+ "profile",
+ "cost_type",
+ "cost_value"
],
"additionalProperties": false,
- "description": "Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux."
+ "description": "Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux."
}
},
"required": [
@@ -2214,6 +2241,122 @@ Cet outil ne peut renvoyer qu'un unique objet (0 ou plusieurs résultats provoqu
| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
| Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). |
+## `gpf_isoline_layer`
+
+Code Source : [src/tools/GpfIsolineLayerTool.ts](../src/tools/GpfIsolineLayerTool.ts)
+
+### Titre
+
+Couche cartographiable d’isochrone / d'isodistance GPF
+
+### Description du tool
+
+```
+Interroge une zone de desserte calculée autour d'un point : isochrone si `cost_type = "time"`, isodistance si `cost_type = "distance"`.
+Renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.
+Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement, `cost_type` pour choisir le type de calcul et `cost_value` pour fixer le seuil maximal (en minutes si `time`, en mètres si `distance`).
+(source : Géoplateforme (calcul d'isochrone / d'isodistance)).
+```
+
+### Schéma d’entrée
+
+| Champ | Type | Requis | Description |
+| --- | --- | --- | --- |
+| `cost_type` | string (enum) | oui | Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance. Valeurs : time, distance. |
+| `cost_value` | number | oui | Valeur du coût maximal. Interprétée en minutes si `cost_type = "time"` (maximum : 600), et en mètres si `cost_type = "distance"` (maximum : 50000). |
+| `lat` | number | oui | Latitude du point de départ en WGS84 `lon/lat`. |
+| `lon` | number | oui | Longitude du point de départ en WGS84 `lon/lat`. |
+| `profile` | string (enum) | oui | Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`. Valeurs : car, pedestrian. |
+
+
+Schéma d’entrée brut
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "lon": {
+ "type": "number",
+ "minimum": -180,
+ "maximum": 180,
+ "description": "Longitude du point de départ en WGS84 `lon/lat`."
+ },
+ "lat": {
+ "type": "number",
+ "minimum": -90,
+ "maximum": 90,
+ "description": "Latitude du point de départ en WGS84 `lon/lat`."
+ },
+ "profile": {
+ "type": "string",
+ "enum": [
+ "car",
+ "pedestrian"
+ ],
+ "description": "Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`."
+ },
+ "cost_type": {
+ "type": "string",
+ "enum": [
+ "time",
+ "distance"
+ ],
+ "description": "Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance."
+ },
+ "cost_value": {
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "description": "Valeur du coût maximal. Interprétée en minutes si `cost_type = \"time\"` (maximum : 600), et en mètres si `cost_type = \"distance\"` (maximum : 50000)."
+ }
+ },
+ "required": [
+ "lon",
+ "lat",
+ "profile",
+ "cost_type",
+ "cost_value"
+ ],
+ "additionalProperties": false,
+ "$schema": "http://json-schema.org/draft-07/schema#"
+}
+```
+
+
+
+### Schéma de sortie
+
+| Champ | Type | Requis | Description |
+| --- | --- | --- | --- |
+| `data_url` | string | oui | URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique. |
+
+
+Schéma de sortie brut
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "data_url": {
+ "type": "string",
+ "description": "URL renvoyant une FeatureCollection GeoJSON (géométries complètes) prête à être affichée dans un outil cartographique.",
+ "format": "uri"
+ }
+ },
+ "required": [
+ "data_url"
+ ]
+}
+```
+
+
+
+### Réponse MCP
+
+| Cas | `content` | `structuredContent` | Relation entre `content` et `structuredContent` |
+| --- | --- | --- | --- |
+| Succès | oui | oui | `content[0].text` est `JSON.stringify(structuredContent)`. |
+| Erreur | oui | non | `content[0].text` porte le message d'erreur ; aucun `structuredContent` n'est ajouté (réservé au `outputSchema` du cas de succès). |
+
## `distance`
Code Source : [src/tools/DistanceTool.ts](../src/tools/DistanceTool.ts)
diff --git a/scripts/generate-mcp-docs.mjs b/scripts/generate-mcp-docs.mjs
index 4b7a4706..cd9e4afc 100644
--- a/scripts/generate-mcp-docs.mjs
+++ b/scripts/generate-mcp-docs.mjs
@@ -23,6 +23,7 @@ const toolDisplayOrder = [
"gpf_count_features",
"gpf_get_feature_by_id",
"gpf_get_feature_by_id_layer",
+ "gpf_isoline_layer",
];
/**
diff --git a/src/config/env.ts b/src/config/env.ts
index a61751fb..ff016cb2 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -225,12 +225,12 @@ const envSchema = z.object({
// Dedicated upstream WFS rate limit for the proxy, separate from GPF_WFS_RATE_LIMIT.
// Both counters hit the same IGN service, so split one allowance across them.
GPF_WFS_PROXY_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(10)),
- // Dedicated isochrone rate limit for the proxy's travel_time leg, separate from
+ // Dedicated isochrone rate limit for the proxy's isoline leg, separate from
// GPF_NAVIGATION_RATE_LIMIT. Both counters hit the same IGN service, so split one
// allowance across them.
GPF_NAVIGATION_PROXY_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(5)),
// Upstream timeout (seconds) for the proxy's WFS AND isochrone calls, shorter than
- // HTTP_TIMEOUT so a 2-call intersects_feature/travel_time stays under the
+ // HTTP_TIMEOUT so a 2-call intersects_feature/isoline stays under the
// browser/Carto fetch timeout.
PROXY_UPSTREAM_TIMEOUT: z.preprocess(emptyToUndefined, positiveNumberSchema.default(10)),
});
diff --git a/src/gpf/itinerary.ts b/src/gpf/itinerary.ts
index 9eba76b7..8ad26a4e 100644
--- a/src/gpf/itinerary.ts
+++ b/src/gpf/itinerary.ts
@@ -3,15 +3,15 @@ import logger from "../logger.js";
import type { JsonFetcher } from "../helpers/http.js";
import type { RateLimiter } from "../helpers/RateLimiter.js";
import { getNavigationRateLimiter } from "./navigationRateLimiter.js";
-import { TRAVEL_TIME_PROFILES, TRAVEL_TIME_RESOURCE } from "./navigation.js";
+import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE } from "./navigation.js";
export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)";
export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire";
// Same engine as the `travel_time_filter` isochrones, so that both report the
// same travel times.
-export const ITINERARY_RESOURCE = TRAVEL_TIME_RESOURCE;
-export const ITINERARY_PROFILES = TRAVEL_TIME_PROFILES;
-export const ITINERARY_METRICS = ["time", "distance"] as const;
+export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE;
+export const ITINERARY_PROFILES = NAVIGATION_PROFILES;
+export const ITINERARY_METRICS = NAVIGATION_METRICS;
export type ItineraryProfile = typeof ITINERARY_PROFILES[number];
export type ItineraryMetric = typeof ITINERARY_METRICS[number];
diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts
index 6c8668aa..add2eb0b 100644
--- a/src/gpf/navigation.ts
+++ b/src/gpf/navigation.ts
@@ -1,43 +1,40 @@
-import { fetchJSONGet } from "../helpers/http.js";
+import { fetchJSONGet, ServiceResponseError } from "../helpers/http.js";
import logger from "../logger.js";
import type { JsonFetcher } from "../helpers/http.js";
import type { Geometry } from "geojson";
import { isGeometryLike } from "../helpers/geojson.js";
import type { RateLimiter } from "../helpers/RateLimiter.js";
import { getNavigationRateLimiter } from "./navigationRateLimiter.js";
+import type { GpfIsolineLayerInput } from "../wfs/schema.js";
-export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone)";
-export const NAVIGATION_ISOCHRONE_URL = "https://data.geopf.fr/navigation/isochrone";
-export const TRAVEL_TIME_RESOURCE = "bdtopo-valhalla";
-export const TRAVEL_TIME_MAX_MINUTES = 120;
-export const TRAVEL_TIME_PROFILES = ["car", "pedestrian"] as const;
+export const NAVIGATION_SOURCE = "Géoplateforme (calcul d'isochrone / d'isodistance)";
+export const NAVIGATION_ISOLINE_URL = "https://data.geopf.fr/navigation/isochrone";
+export const NAVIGATION_ISOLINE_RESOURCE = "bdtopo-valhalla";
+// Upstream ceilings accepted by the GPF isochrone service, per cost type.
+export const NAVIGATION_ISOCHRONE_MAX_MINUTES = 600;
+export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000;
+export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const;
+export const NAVIGATION_METRICS = ["time", "distance"] as const;
-export type TravelTimeProfile = typeof TRAVEL_TIME_PROFILES[number];
+export type NavigationProfile = typeof NAVIGATION_PROFILES[number];
+export type NavigationMetrics = typeof NAVIGATION_METRICS[number];
-
-export type TravelTimeGeometryInput = {
- lon: number;
- lat: number;
- minutes: number;
- profile: TravelTimeProfile;
-};
-
-export class NavigationIsochroneClient {
+export class NavigationIsolineClient {
constructor(
private rateLimiter: RateLimiter,
private fetcher: JsonFetcher<{geometry?: unknown}> = fetchJSONGet,
) {}
- async getTravelTimeGeometry(input: TravelTimeGeometryInput): Promise {
+ async getIsoline(input: GpfIsolineLayerInput): Promise {
await this.rateLimiter.limit();
- logger.debug(`[gpf:navigation] getTravelTimeGeometry(${JSON.stringify(input)})...`);
+ logger.debug(`[gpf:navigation] getGeometry(${JSON.stringify(input)})...`);
- const url = `${NAVIGATION_ISOCHRONE_URL}?${new URLSearchParams({
- resource: TRAVEL_TIME_RESOURCE,
+ const url = `${NAVIGATION_ISOLINE_URL}?${new URLSearchParams({
+ resource: NAVIGATION_ISOLINE_RESOURCE,
point: `${input.lon},${input.lat}`,
direction: "departure",
- costType: "time",
- costValue: String(input.minutes),
+ costType: input.cost_type,
+ costValue: String(input.cost_value),
profile: input.profile,
timeUnit: "minute",
distanceUnit: "meter",
@@ -47,22 +44,24 @@ export class NavigationIsochroneClient {
const json = await this.fetcher(url);
if (!isGeometryLike(json.geometry)) {
- throw new Error("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable.");
+ throw new ServiceResponseError("Le service d'isochrone n'a pas renvoyé de géométrie GeoJSON exploitable.", {
+ http: { status: 502, statusText: "Bad Gateway" },
+ });
}
return json.geometry;
}
}
-let defaultNavigationIsochroneClient: NavigationIsochroneClient | undefined;
+let defaultNavigationIsolineClient: NavigationIsolineClient | undefined;
-function getDefaultNavigationIsochroneClient() {
- defaultNavigationIsochroneClient ??= new NavigationIsochroneClient(getNavigationRateLimiter());
- return defaultNavigationIsochroneClient;
+function getDefaultNavigationIsolineClient() {
+ defaultNavigationIsolineClient ??= new NavigationIsolineClient(getNavigationRateLimiter());
+ return defaultNavigationIsolineClient;
}
-export const navigationIsochroneClient = {
- getTravelTimeGeometry(input: TravelTimeGeometryInput) {
- return getDefaultNavigationIsochroneClient().getTravelTimeGeometry(input);
+export const navigationIsolineClient = {
+ getIsoline(input: GpfIsolineLayerInput) {
+ return getDefaultNavigationIsolineClient().getIsoline(input);
},
};
diff --git a/src/helpers/http.ts b/src/helpers/http.ts
index bdd2e739..481d1b0a 100644
--- a/src/helpers/http.ts
+++ b/src/helpers/http.ts
@@ -402,7 +402,7 @@ export async function fetchJSONPostWithLimit(
/**
* GET variant of the size-bounded transport, parsing the bounded body as JSON.
- * Used by the proxy's isochrone leg so a `travel_time` layer request goes
+ * Used by the proxy's isochrone leg so an `isoline` layer request goes
* through the SAME `PROXY_UPSTREAM_TIMEOUT` + `PROXY_MAX_RESPONSE_BYTES` bounds
* as its WFS leg, instead of the unbounded `HTTP_TIMEOUT`-only `fetchJSONGet`.
*
diff --git a/src/proxy/execute.ts b/src/proxy/execute.ts
index dc0e9568..35f6f276 100644
--- a/src/proxy/execute.ts
+++ b/src/proxy/execute.ts
@@ -1,9 +1,9 @@
/**
- * Proxy-side WFS execution engine.
+ * Proxy-side WFS and isochrone execution engine.
*
* `runGeometryFeatureQuery` (entry point) compiles and runs the layer query;
* `resolveReferenceGeometry` (internal helper) resolves the reference geometry
- * for `intersects_feature` / `travel_time` filters.
+ * for `intersects_feature` / `isoline` filters.
*
* Unlike the LLM-facing `executeQueryFeatures` (which strips geometry to `null`
* via `postProcessFeatureCollection` to save tokens), the proxy needs the OPPOSITE: a
@@ -37,8 +37,12 @@ import { resolveFeatureGeometry } from "../wfs/referenceGeometry.js";
import { rethrowIdentifiedCatalogDesyncError } from "../wfs/catalogDesync.js";
import { ServiceResponseError, extractJsonServiceError } from "../helpers/http.js";
import type { WfsFeatureCollectionResponse } from "../wfs/types.js";
-import type { GpfGetFeaturesInput, GpfGetFeatureByIdLayerInput } from "../wfs/schema.js";
-import type { Geometry } from "geojson";
+import type { FeatureCollection, Geometry } from "geojson";
+import type {
+ GpfGetFeaturesInput,
+ GpfGetFeatureByIdLayerInput,
+ GpfIsolineLayerInput,
+} from "../wfs/schema.js";
// --- Injected Dependencies ---
@@ -53,21 +57,21 @@ export type WfsClientLike = {
};
/**
- * Resolves the isochrone geometry for a `travel_time` filter. Injected by
+ * Resolves the isochrone/isodistance geometry for an `isoline` filter. Injected by
* the HTTP layer (backed by the navigation/isochrone service). Required, because
- * `travel_time` is part of the `gpf_get_features` query contract the proxy must
+ * `isoline` is part of the `gpf_get_features` query contract the proxy must
* honour — it is not an optional capability. The engine stays isochrone-agnostic
* (pure, network-free, testable), exactly as it is for `wfsClient`.
*/
-export type TravelTimeResolver = (input: GpfGetFeaturesInput) => Promise;
+export type IsolineResolver = (input: GpfGetFeaturesInput) => Promise;
/**
* Dependencies injected into {@link runGeometryFeatureQuery}.
*/
export type GeometryFeatureQueryDeps = {
wfsClient: WfsClientLike;
- /** Isochrone resolver, invoked only for `travel_time` filters. */
- resolveTravelTime: TravelTimeResolver;
+ /** Isochrone/isodistance resolver, invoked only for `isoline` filters. */
+ resolveIsoline: IsolineResolver;
};
// --- Internal Helpers ---
@@ -157,10 +161,10 @@ async function resolveReferenceGeometry(
): Promise {
const spatialFilter = getSpatialFilter(input);
- // travel_time is resolved by the injected isochrone resolver, up front, so
+ // isoline is resolved by the injected isochrone/isodistance resolver, up front, so
// compileQueryParts never sees an unresolved ref (symmetric to intersects_feature).
- if (spatialFilter?.operator === "travel_time") {
- return deps.resolveTravelTime(input);
+ if (spatialFilter?.operator === "isoline") {
+ return deps.resolveIsoline(input);
}
if (!spatialFilter || spatialFilter.operator !== "intersects_feature") {
@@ -186,7 +190,7 @@ async function resolveReferenceGeometry(
* @param input Validated layer query input (same shape as `gpf_get_features`
* minus the LLM-only `spatial_extras` knob).
* @param deps Injected WFS client (catalog + execution) and isochrone resolver
- * (always required; invoked only for `travel_time` filters).
+ * (always required; invoked only for `isoline` filters).
* @returns The raw WFS FeatureCollection, geometry preserved.
*/
export async function runGeometryFeatureQuery(
@@ -302,3 +306,43 @@ export async function runGeometryFeatureByIdQuery(
numberMatched: 1,
};
}
+
+// --- Isochrone Public Engine ---
+
+export type IsolineGeometryResolver = (
+ input: GpfIsolineLayerInput,
+) => Promise;
+
+export type GeometryIsolineQueryDeps = {
+ getGeometry: IsolineGeometryResolver;
+};
+
+/**
+ * Resolves an isoline (isochrone or isodistance) and returns it as a
+ * GeoJSON `FeatureCollection` with full geometry (for map rendering by MCP Carto).
+ *
+ * Counterpart of {@link runGeometryFeatureQuery} for the isoline producer tool.
+ * The request params are echoed into `properties` so the rendered layer carries
+ * its own legend.
+ *
+ * @param input Validated isoline layer input (`{ lon, lat, profile, cost_type, cost_value }`).
+ * @param deps Injected isoline geometry resolver.
+ * @returns The isoline as a GeoJSON FeatureCollection.
+ */
+export async function runGeometryIsolineQuery(
+ input: GpfIsolineLayerInput,
+ deps: GeometryIsolineQueryDeps,
+): Promise {
+ const geometry = await deps.getGeometry(input);
+
+ return {
+ type: "FeatureCollection" as const,
+ features: [
+ {
+ type: "Feature" as const,
+ geometry,
+ properties: input,
+ }
+ ]
+ };
+}
diff --git a/src/proxy/server.ts b/src/proxy/server.ts
index 7d8951c8..0af7d89d 100644
--- a/src/proxy/server.ts
+++ b/src/proxy/server.ts
@@ -2,8 +2,8 @@
* Stateless geodata proxy HTTP server.
*
* Serves `GET {PROXY_ENDPOINT}/.json`: decode the opaque token, re-validate
- * it through the layer schema, run the geometry-full WFS query, and return a
- * GeoJSON FeatureCollection for MCP Carto to render.
+ * it through the layer schema, run the geometry-full WFS or isochrone query, and
+ * return a GeoJSON FeatureCollection for MCP Carto to render.
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
@@ -14,13 +14,19 @@ import { GPF_WFS_URL } from "../wfs/catalog.js";
import {
gpfGetFeaturesLayerInputSchema,
gpfGetFeatureByIdLayerInputObjectSchema,
+ gpfIsolineLayerInputSchema,
PROXY_TOKEN_KIND,
} from "../wfs/schema.js";
-import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery } from "./execute.js";
+import {
+ runGeometryFeatureQuery,
+ runGeometryFeatureByIdQuery,
+ runGeometryIsolineQuery,
+} from "./execute.js";
import { FeatureNotFoundError, FeatureCardinalityError } from "../wfs/byId.js";
import {
getDefaultGeometryFeatureQueryDeps,
getDefaultGeometryFeatureByIdQueryDeps,
+ getDefaultGeometryIsolineQueryDeps,
} from "./transport.js";
import {
decodeToken,
@@ -72,7 +78,7 @@ function toHttpError(error: unknown): HttpError {
if (error instanceof FeatureCardinalityError) {
// The client request was valid but the upstream WFS broke the single-feature
// contract (duplicate / id mismatch / unusable body): an upstream anomaly.
- return { status: 502, detail: "Le service WFS a renvoyé une réponse incohérente pour cet objet." };
+ return { status: 502, detail: "Le service amont a renvoyé une réponse incohérente pour cet objet." };
}
if (error instanceof ServiceResponseError) {
const upstream = error.httpStatus ?? 502;
@@ -80,13 +86,13 @@ function toHttpError(error: unknown): HttpError {
// timeout (504). Client-provided data already passed validation, so a 4xx here
// means an upstream contract issue, not a client error on the proxy endpoint.
const status = upstream === 504 ? 504 : 502;
- // Do NOT forward error.serviceDetail (raw upstream WFS text, English, internal
+ // Do NOT forward error.serviceDetail (raw upstream WFS or GPF text, English, internal
// column names) to the client: like the other branches, return a fixed FR
// message. The upstream detail is logged server-side by the caller.
const detail =
status === 504
- ? "Le service WFS n'a pas répondu à temps."
- : "Le service WFS a renvoyé une réponse inexploitable.";
+ ? "Le service amont n'a pas répondu à temps."
+ : "Le service amont a renvoyé une réponse inexploitable.";
return { status, detail };
}
return { status: 500, detail: "Erreur interne du proxy." };
@@ -164,7 +170,7 @@ async function handleLayerRequest(token: string, res: ServerResponse): Promise fetchJSONGetWithLimit(url, getEnv().PROXY_UPSTREAM_TIMEOUT * 1000, getEnv().PROXY_MAX_RESPONSE_BYTES, "d'isochrone"),
);
- return cachedProxyIsochroneClient;
+ return cachedProxyIsolineClient;
}
-// --- Reference-geometry resolver (travel_time / isochrone) ---
+// --- Reference-geometry resolver ---
/**
- * Reference-geometry resolver for the `travel_time` spatial filter: turns the
- * isochrone into a reference geometry that is fed INTO the WFS query — the
+ * Reference-geometry resolver for the `isoline` spatial filter: turns the
+ * isoline into a reference geometry that is fed INTO the WFS query — the
* sibling of `intersects_feature`'s reference-geometry resolution
* (`resolveFeatureGeometry`). It does NOT fetch features itself (that is the
- * WFS transport's job). Backed by the proxy isochrone client (bounded fetch +
+ * WFS transport's job). Backed by the proxy isoline client (bounded fetch +
* `GPF_NAVIGATION_PROXY` rate limiter), and injected into `runGeometryFeatureQuery`
- * so it only fires for travel_time inputs.
+ * so it only fires for isoline inputs.
*/
-export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async (
+export const resolveProxyIsolineGeometry: IsolineResolver = async (
input: GpfGetFeaturesInput,
): Promise => {
const spatialFilter = getSpatialFilter(input);
- if (spatialFilter?.operator !== "travel_time") {
- // Guarded by the caller (runGeometryFeatureQuery only calls this for travel_time);
+ if (spatialFilter?.operator !== "isoline") {
+ // Guarded by the caller (runGeometryFeatureQuery only calls this for isoline);
// defensive check keeps the type narrow.
- throw new Error("resolveProxyTravelTimeGeometry appelé sans filtre `travel_time`.");
+ throw new Error("resolveProxyIsolineGeometry appelé sans filtre `isoline`.");
}
- return await getProxyIsochroneClient().getTravelTimeGeometry({
- lon: spatialFilter.lon,
- lat: spatialFilter.lat,
- minutes: spatialFilter.minutes,
- profile: spatialFilter.profile,
- });
+ const { operator, ...parameters } = spatialFilter;
+
+ return await getProxyIsolineClient().getIsoline(parameters);
};
// --- Default Engine Dependencies ---
/**
* Default (production) dependency bundle for `runGeometryFeatureQuery`: the proxy
- * WFS client and the proxy isochrone resolver. Bundling the concrete proxy wiring
+ * WFS client and the proxy isoline resolver. Bundling the concrete proxy wiring
* here keeps `server.ts` decoupled from the individual clients — it asks the
* transport layer for "the deps" instead of assembling them itself. Tests inject
* their own deps into the engine directly.
@@ -144,17 +142,26 @@ export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async (
export function getDefaultGeometryFeatureQueryDeps(): GeometryFeatureQueryDeps {
return {
wfsClient: getProxyWfsClient(),
- resolveTravelTime: resolveProxyTravelTimeGeometry,
+ resolveIsoline: resolveProxyIsolineGeometry,
};
}
/**
* Default (production) dependency bundle for `runGeometryFeatureByIdQuery`.
* Narrower than {@link getDefaultGeometryFeatureQueryDeps}: a by-id lookup has no
- * spatial filter, so it needs only the WFS client (no isochrone resolver).
+ * spatial filter, so it needs only the WFS client (no isoline resolver).
*/
export function getDefaultGeometryFeatureByIdQueryDeps(): GeometryFeatureByIdQueryDeps {
return {
wfsClient: getProxyWfsClient(),
};
}
+
+/**
+ * Default dependency bundle for `runGeometryIsolineQuery`.
+ */
+export function getDefaultGeometryIsolineQueryDeps(): GeometryIsolineQueryDeps {
+ return {
+ getGeometry: (input) => getProxyIsolineClient().getIsoline(input),
+ };
+}
diff --git a/src/tools/GpfCountFeaturesTool.ts b/src/tools/GpfCountFeaturesTool.ts
index fcd91205..924ab257 100644
--- a/src/tools/GpfCountFeaturesTool.ts
+++ b/src/tools/GpfCountFeaturesTool.ts
@@ -31,7 +31,8 @@ const GPF_COUNT_FEATURES_TOOL_DESCRIPTION = [
"Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.",
"Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.",
"Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.",
- "Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
+ "Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"pedestrian\", cost_type: \"time\", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
+ "Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"car\", cost_type: \"distance\", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.",
"⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.",
"**OBLIGATOIRE dès que `where` est utilisé : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Un comptage par `typename` et filtre spatial seul (sans `where`) ne référence aucun nom de propriété et ne nécessite pas cet appel préalable.",
"Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute clause `where` sans appel préalable à `gpf_describe_type` **provoquera une erreur.**",
diff --git a/src/tools/GpfGetFeaturesLayerTool.ts b/src/tools/GpfGetFeaturesLayerTool.ts
index b97c2bae..f2db7ce1 100644
--- a/src/tools/GpfGetFeaturesLayerTool.ts
+++ b/src/tools/GpfGetFeaturesLayerTool.ts
@@ -140,12 +140,12 @@ class GpfGetFeaturesLayerTool extends BaseTool {
getGeometryName(referenceType);
}
- // We must NOT resolve the reference geometry for intersects_feature/travel_time
+ // We must NOT resolve the reference geometry for intersects_feature/isoline
// (that IS a network call) — those geometries resolve at fetch time on the
// proxy — so we pass a placeholder ref just to let compileQueryParts validate
// the attribute side (select/where/order_by).
const needsResolvedRef =
- spatialFilter?.operator === "intersects_feature" || spatialFilter?.operator === "travel_time";
+ spatialFilter?.operator === "intersects_feature" || spatialFilter?.operator === "isoline";
compileQueryParts(
compiledInput,
featureType,
diff --git a/src/tools/GpfGetFeaturesTool.ts b/src/tools/GpfGetFeaturesTool.ts
index f1a83e0c..4aac90b7 100644
--- a/src/tools/GpfGetFeaturesTool.ts
+++ b/src/tools/GpfGetFeaturesTool.ts
@@ -32,7 +32,8 @@ const GPF_GET_FEATURES_TOOL_DESCRIPTION = [
"Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.",
"Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.",
"Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.",
- "Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
+ "Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"pedestrian\", cost_type: \"time\", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
+ "Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"car\", cost_type: \"distance\", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.",
"⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.",
"**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**",
"Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_describe_type` **provoquera une erreur.**",
diff --git a/src/tools/GpfIsolineLayerTool.ts b/src/tools/GpfIsolineLayerTool.ts
new file mode 100644
index 00000000..c0a0e9a9
--- /dev/null
+++ b/src/tools/GpfIsolineLayerTool.ts
@@ -0,0 +1,107 @@
+/**
+ * MCP tool producing an opaque, cartographiable layer URL for a Géoplateforme
+ * isochrone or isodistance request.
+ *
+ * The tool returns a short opaque `data_url` that the LLM passes verbatim to a
+ * map client. Fetching it yields a GeoJSON FeatureCollection served by the
+ * stateless geodata proxy. The URL encodes the validated request params as an
+ * opaque token, so the LLM can neither parse nor rebuild the underlying
+ * upstream request.
+ */
+
+import BaseTool from "./BaseTool.js";
+
+import { READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js";
+import { getEnv } from "../config/env.js";
+import { encodeToken } from "../proxy/token.js";
+import { buildDataUrl } from "../proxy/dataUrl.js";
+import {
+ PROXY_TOKEN_KIND,
+ gpfGetFeaturesLayerOutputSchema,
+ gpfIsolineLayerInputObjectSchema,
+ gpfIsolineLayerInputSchema,
+ gpfIsolineLayerPublishedInputSchema,
+ type GpfIsolineLayerInput,
+} from "../wfs/schema.js";
+import { NAVIGATION_SOURCE } from "../gpf/navigation.js";
+import logger from "../logger.js";
+
+const GPF_ISOLINE_LAYER_TOOL_DESCRIPTION = [
+ "Interroge une zone de desserte calculée autour d'un point : isochrone si `cost_type = \"time\"`, isodistance si `cost_type = \"distance\"`.",
+ "Renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec une géométrie complète.",
+ "Utiliser `lon`/`lat` pour le point de départ, `profile` pour le mode de déplacement, `cost_type` pour choisir le type de calcul et `cost_value` pour fixer le seuil maximal (en minutes si `time`, en mètres si `distance`).",
+ `(source : ${NAVIGATION_SOURCE}).`,
+].join("\n");
+
+// --- Tool ---
+
+class GpfIsolineLayerTool extends BaseTool {
+ name = "gpf_isoline_layer";
+ title = "Couche cartographiable d’isochrone / d'isodistance GPF";
+ annotations = READ_ONLY_CLOSED_WORLD_TOOL_ANNOTATIONS;
+ description = GPF_ISOLINE_LAYER_TOOL_DESCRIPTION;
+ protected outputSchemaShape = gpfGetFeaturesLayerOutputSchema;
+
+ // The framework requires a plain Zod object here to publish a compatible input
+ // schema.
+ schema = gpfIsolineLayerInputObjectSchema;
+
+ /**
+ * Exposes an input schema variant that stays compatible with most MCP integrations.
+ *
+ * @returns The published input schema exposed through the MCP tool definition.
+ */
+ get inputSchema() {
+ return gpfIsolineLayerPublishedInputSchema;
+ }
+
+ /**
+ * Formats the `{ data_url }` response into `structuredContent`.
+ *
+ * @param data Raw execution result returned by the tool implementation.
+ * @returns An MCP success response enriched with structured content.
+ */
+ protected createSuccessResponse(data: unknown) {
+ const payload = gpfGetFeaturesLayerOutputSchema.parse(data);
+
+ return {
+ content: [{ type: "text" as const, text: JSON.stringify(payload) }],
+ structuredContent: payload,
+ };
+ }
+
+ /**
+ * Mints the opaque proxy URL for the requested isoline. No upstream call is
+ * made here: the isoline itself is computed by the proxy when the `data_url`
+ * is fetched.
+ *
+ * @param input Validated isoline layer input.
+ * @returns The `{ data_url }` payload carrying the opaque token.
+ */
+ async execute(input: GpfIsolineLayerInput) {
+ const env = getEnv();
+
+ if (!env.PROXY_URL_SECRET || !env.PROXY_PUBLIC_BASE_URL) {
+ throw new Error(
+ "`gpf_isoline_layer` nécessite un proxy geodata configuré (variables d'environnement `PROXY_URL_SECRET` et `PROXY_PUBLIC_BASE_URL`, pointant vers un proxy joignable).",
+ );
+ }
+
+ const tokenParams = gpfIsolineLayerInputSchema.parse(input);
+
+ logger.info(`[tool] execute ${this.name} ...`, {
+ input: tokenParams,
+ });
+
+ const token = encodeToken(
+ { kind: PROXY_TOKEN_KIND.isoline, ...tokenParams },
+ env.PROXY_URL_SECRET,
+ );
+
+ const dataUrl = buildDataUrl(env.PROXY_PUBLIC_BASE_URL, env.PROXY_ENDPOINT, token);
+
+ return { data_url: dataUrl };
+ }
+}
+
+export default GpfIsolineLayerTool;
diff --git a/src/wfs/features.ts b/src/wfs/features.ts
index 2caeb8fc..6f11c4ae 100644
--- a/src/wfs/features.ts
+++ b/src/wfs/features.ts
@@ -6,7 +6,7 @@
* hit counting, and FeatureCollection post-processing.
*/
-import { navigationIsochroneClient } from "../gpf/navigation.js";
+import { navigationIsolineClient } from "../gpf/navigation.js";
import logger from "../logger.js";
import { resolveFeatureGeometry } from "./referenceGeometry.js";
import { rethrowIdentifiedCatalogDesyncError } from "./catalogDesync.js";
@@ -96,25 +96,22 @@ export async function resolveIntersectsFeatureGeometry(
}
/**
- * Resolves the travel-time isochrone geometry when `travel_time_filter` is used.
+ * Resolves the isoline geometry (isochrone or isodistance) when `isoline_filter` is used.
*
* @param input Normalized tool input.
- * @returns The resolved isochrone geometry, or `undefined` when no travel-time filter is requested.
+ * @returns The resolved isoline geometry, or `undefined` when no isoline filter is requested.
*/
-export async function resolveTravelTimeGeometry(
+export async function resolveIsolineGeometry(
input: GpfQueryFeaturesInput,
): Promise {
const spatialFilter = getSpatialFilter(input);
- if (!spatialFilter || spatialFilter.operator !== "travel_time") {
+ if (!spatialFilter || spatialFilter.operator !== "isoline") {
return undefined;
}
- return await navigationIsochroneClient.getTravelTimeGeometry({
- lon: spatialFilter.lon,
- lat: spatialFilter.lat,
- minutes: spatialFilter.minutes,
- profile: spatialFilter.profile,
- });
+ const { operator, ...parameters } = spatialFilter;
+
+ return await navigationIsolineClient.getIsoline(parameters);
}
/**
@@ -131,8 +128,8 @@ export async function resolveSpatialFilterGeometry(
switch (spatialFilter?.operator) {
case "intersects_feature":
return resolveIntersectsFeatureGeometry(input);
- case "travel_time":
- return resolveTravelTimeGeometry(input);
+ case "isoline":
+ return resolveIsolineGeometry(input);
default:
return undefined;
}
diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts
index d3cc9a45..f01d9e0f 100644
--- a/src/wfs/queryPreparation.ts
+++ b/src/wfs/queryPreparation.ts
@@ -209,9 +209,9 @@ export function compileQueryParts(
}
fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef));
break;
- case "travel_time":
+ case "isoline":
if (!resolvedGeometryRef) {
- throw new Error("Le filtre spatial `travel_time` exige la résolution préalable de la géométrie d'isochrone.");
+ throw new Error("Le filtre spatial `isoline` exige la résolution préalable de la géométrie d'isochrone/isodistance.");
}
fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef));
break;
diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts
index 457bc330..282a34fc 100644
--- a/src/wfs/schema.ts
+++ b/src/wfs/schema.ts
@@ -10,7 +10,13 @@ import { z } from "zod";
import { generatePublishedInputSchema } from "../helpers/jsonSchema.js";
import { lonSchema, latSchema } from "../helpers/schemas.js";
-import { TRAVEL_TIME_MAX_MINUTES, TRAVEL_TIME_PROFILES } from "../gpf/navigation.js";
+import {
+ NAVIGATION_METRICS,
+ NAVIGATION_PROFILES,
+ NAVIGATION_ISOCHRONE_MAX_MINUTES,
+ NAVIGATION_ISODISTANCE_MAX_METERS,
+ type NavigationMetrics,
+} from "../gpf/navigation.js";
// --- Shared Constants ---
@@ -91,19 +97,74 @@ const intersectsFeatureFilterSchema = z.object({
feature_id: z.string().trim().min(1).describe("Identifiant du feature de référence."),
}).strict().describe("Filtre les objets dont la géométrie intersecte celle d'un objet GPF de référence.");
-const travelTimeFilterSchema = z.object({
+const navigationProfileSchema = z
+ .enum(NAVIGATION_PROFILES)
+ .describe("Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`.");
+
+// Departure point of an isoline. Flat `lon`/`lat`, exactly like every spatial
+// filter (`intersects_point_filter`, `dwithin_point_filter`, ...), so the LLM sees
+// one point convention across the whole surface.
+const isolinePointSchema = z.object({
lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."),
lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."),
- minutes: z
- .number()
- .finite()
- .positive()
- .max(TRAVEL_TIME_MAX_MINUTES)
- .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`),
- profile: z
- .enum(TRAVEL_TIME_PROFILES)
- .describe("Mode de déplacement utilisé pour calculer l'isochrone (`car` ou `pedestrian`)."),
-}).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point.");
+}).strict();
+
+const navigationMetricsSchema = z
+ .enum(NAVIGATION_METRICS)
+ .describe("Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance.");
+
+type CostLimits = Record
+
+// One max per cost type: `cost_value` is minutes for `time` and meters for
+// `distance`, so the ceiling can only be checked once `cost_type` is known.
+const ISOLINE_COST_LIMITS: CostLimits = {
+ time: { max: NAVIGATION_ISOCHRONE_MAX_MINUTES, name: "temps", unit: "minutes" },
+ distance: { max: NAVIGATION_ISODISTANCE_MAX_METERS, name: "distance", unit: "mètres" },
+};
+
+// Lower time limit for `isoline_filter`, for performance.
+const ISOLINE_FILTER_MAX_TIME_MINUTES = 120;
+
+const ISOLINE_FILTER_COST_LIMITS: CostLimits = {
+ ...ISOLINE_COST_LIMITS,
+ time: { ...ISOLINE_COST_LIMITS.time, max: ISOLINE_FILTER_MAX_TIME_MINUTES },
+}
+
+// `cost_value` is described from the same limits `assertIsolineCostValue` enforces,
+// so the LLM is never told a maximum the schema does not apply.
+function buildIsolineCostSchema(limits: CostLimits) {
+ return z.object({
+ profile: navigationProfileSchema,
+ cost_type: navigationMetricsSchema,
+ cost_value: z
+ .number()
+ .finite()
+ .positive()
+ .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${limits.time.max}), et en mètres si \`cost_type = \"distance\"\` (maximum : ${limits.distance.max}).`),
+ }).strict();
+}
+
+function assertIsolineCostValue(limits: CostLimits) {
+ return (input: { cost_type: NavigationMetrics; cost_value: number }, ctx: z.RefinementCtx) => {
+ const { max, name, unit } = limits[input.cost_type];
+
+ if (input.cost_value > max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.too_big,
+ maximum: max,
+ type: "number",
+ inclusive: true,
+ path: ["cost_value"],
+ message: `Le coût maximal en ${name} ne peut pas dépasser ${max} ${unit}.`,
+ });
+ }
+ }
+}
+
+const isolineFilterSchema = isolinePointSchema
+ .merge(buildIsolineCostSchema(ISOLINE_FILTER_COST_LIMITS))
+ .superRefine(assertIsolineCostValue(ISOLINE_FILTER_COST_LIMITS))
+ .describe("Filtre les objets situés dans une isochrone (temps de trajet maximum fixé) ou une isodistance (distance maximale fixée) autour d'un point.");
// --- Shared GPF Inputs ---
@@ -132,13 +193,13 @@ const gpfSpatialFilterInputSchema = z.object({
.describe("Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux."),
dwithin_point_filter: dwithinPointFilterSchema
.optional()
- .describe("Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux."),
+ .describe("Filtre spatial par distance à un point à vol d'oiseau. Exclusif avec les autres filtres spatiaux."),
intersects_feature_filter: intersectsFeatureFilterSchema
.optional()
.describe("Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux."),
- travel_time_filter: travelTimeFilterSchema
+ isoline_filter: isolineFilterSchema
.optional()
- .describe("Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux."),
+ .describe("Filtre spatial par temps de trajet (isochrone) ou par distance (isodistance) depuis un point avec un profil voiture ou piéton. Exclusif avec les autres filtres spatiaux."),
})
export const GPF_GET_FEATURES_SPATIAL_FILTER_KEYS =
@@ -384,14 +445,15 @@ export const gpfGetFeaturesLayerOutputSchema = z.object({
// --- Proxy token discriminant ---
// The proxy serves ONE opaque token (in the URL path, `${endpoint}/.json`)
-// but two token kinds (a filtered layer query and a single-feature by-id lookup).
-// Both producer tools stamp their token
+// but several token kinds (a filtered layer query, a single-feature by-id lookup
+// and an isoline). Every producer tool stamps its token
// with this `kind` discriminant; the proxy reads it to dispatch to the right
// schema + engine, then strips it before the strict per-kind `.parse`. It is
// injected by the tool from validated params — never an LLM-supplied field.
export const PROXY_TOKEN_KIND = {
query: "query",
byId: "by_id",
+ isoline: "isoline",
} as const;
export type ProxyTokenKind = (typeof PROXY_TOKEN_KIND)[keyof typeof PROXY_TOKEN_KIND];
@@ -430,6 +492,19 @@ export type GpfGetFeatureByIdLayerInput = z.infer;
+
+export const gpfIsolineLayerPublishedInputSchema = generatePublishedInputSchema(gpfIsolineLayerInputObjectSchema);
+
// --- `gpf_count_features` ---
export const gpfCountFeaturesInputObjectSchema = gpfTypenameInputSchema
diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts
index 2f226161..497f411d 100644
--- a/src/wfs/spatialExtras.ts
+++ b/src/wfs/spatialExtras.ts
@@ -37,7 +37,7 @@ function spatialFilterToGeometry(spatialFilter: SpatialFilter, resolvedGeometryR
return { type: "Point", coordinates: point };
}
case "intersects_feature":
- case "travel_time":
+ case "isoline":
if (!resolvedGeometryRef) {
throw new Error(`Le filtre spatial \`${spatialFilter.operator}\` exige la résolution préalable de la géométrie de référence.`);
}
@@ -51,7 +51,7 @@ function spatialFilterToGeometry(spatialFilter: SpatialFilter, resolvedGeometryR
function spatialFilterToCentroid(spatialFilter: SpatialFilter, resolvedGeometryRef?: Geometry) : Point {
switch (spatialFilter.operator) {
case "dwithin_point" :
- case "travel_time":
+ case "isoline":
case "intersects_point": {
return { type: "Point", coordinates: [spatialFilter.lon, spatialFilter.lat] };
}
@@ -190,7 +190,7 @@ function intersectionAreaWithSpatialFilter(geom: Geometry, spatialFilter: Spatia
// lies within `distance_m`, so the intersection is needed even for it.
case "dwithin_point":
case "intersects_feature":
- case "travel_time":
+ case "isoline":
case "bbox": {
if (!intersectionArea) return null; // non-areal filter, or filter preparation failed
return intersectionArea(geo);
diff --git a/test/gpf/navigation.test.ts b/test/gpf/navigation.test.ts
index c96cf1e0..9bc144a0 100644
--- a/test/gpf/navigation.test.ts
+++ b/test/gpf/navigation.test.ts
@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
-import { NavigationIsochroneClient } from "../../src/gpf/navigation.js";
+import { NavigationIsolineClient } from "../../src/gpf/navigation.js";
import { RateLimiter } from "../../src/helpers/RateLimiter.js";
-describe("NavigationIsochroneClient", () => {
- it("should build a Valhalla travel-time isochrone request and return its GeoJSON geometry", async () => {
+describe("NavigationIsolineClient", () => {
+ it("should build a Valhalla isochrone request and return its GeoJSON geometry", async () => {
const urls: string[] = [];
- const client = new NavigationIsochroneClient(
+ const client = new NavigationIsolineClient(
new RateLimiter({ name: "test", maxCalls: 100, period: 1 }),
async (url) => {
urls.push(url);
@@ -21,10 +21,11 @@ describe("NavigationIsochroneClient", () => {
},
);
- const geometry = await client.getTravelTimeGeometry({
+ const geometry = await client.getIsoline({
lon: 2.337306,
lat: 48.849319,
- minutes: 15,
+ cost_type: "time",
+ cost_value: 15,
profile: "pedestrian",
});
@@ -43,16 +44,57 @@ describe("NavigationIsochroneClient", () => {
expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("geojson");
});
+ it("should build an isodistance request and return its GeoJSON geometry", async () => {
+ const urls: string[] = [];
+ const client = new NavigationIsolineClient(
+ new RateLimiter({ name: "test", maxCalls: 100, period: 1 }),
+ async (url) => {
+ urls.push(url);
+ return {
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [[2.338306, 48.849753], [2.337306, 48.850142], [2.338306, 48.849753]],
+ ],
+ },
+ };
+ },
+ );
+
+ const geometry = await client.getIsoline({
+ lon: 2.337306,
+ lat: 48.849319,
+ cost_type: "distance",
+ cost_value: 1200,
+ profile: "car",
+ });
+
+ expect(geometry.type).toEqual("Polygon");
+ const parsedUrl = new URL(urls[0]);
+ expect(parsedUrl.origin + parsedUrl.pathname).toEqual("https://data.geopf.fr/navigation/isochrone");
+ expect(parsedUrl.searchParams.get("resource")).toEqual("bdtopo-valhalla");
+ expect(parsedUrl.searchParams.get("point")).toEqual("2.337306,48.849319");
+ expect(parsedUrl.searchParams.get("direction")).toEqual("departure");
+ expect(parsedUrl.searchParams.get("costType")).toEqual("distance");
+ expect(parsedUrl.searchParams.get("costValue")).toEqual("1200");
+ expect(parsedUrl.searchParams.get("profile")).toEqual("car");
+ expect(parsedUrl.searchParams.get("timeUnit")).toEqual("minute");
+ expect(parsedUrl.searchParams.get("distanceUnit")).toEqual("meter");
+ expect(parsedUrl.searchParams.get("crs")).toEqual("EPSG:4326");
+ expect(parsedUrl.searchParams.get("geometryFormat")).toEqual("geojson");
+ });
+
it("should reject responses without usable GeoJSON geometry", async () => {
- const client = new NavigationIsochroneClient(
+ const client = new NavigationIsolineClient(
new RateLimiter({ name: "test", maxCalls: 100, period: 1 }),
async () => ({ geometry: null }),
);
- await expect(client.getTravelTimeGeometry({
+ await expect(client.getIsoline({
lon: 2.337306,
lat: 48.849319,
- minutes: 15,
+ cost_type: "time",
+ cost_value: 15,
profile: "car",
})).rejects.toThrow("géométrie GeoJSON exploitable");
});
diff --git a/test/integration/samples.ts b/test/integration/samples.ts
index e85668c5..fa89d23a 100644
--- a/test/integration/samples.ts
+++ b/test/integration/samples.ts
@@ -25,6 +25,7 @@ export const EXPECTED_TOOL_NAMES = [
"gpf_get_features",
"gpf_get_feature_by_id",
"gpf_count_features",
+ "gpf_isoline_layer",
"gpf_get_features_layer",
"gpf_get_feature_by_id_layer",
] as const;
diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts
index 11e7c0e3..b76413c9 100644
--- a/test/proxy/execute.test.ts
+++ b/test/proxy/execute.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { OgcCollectionSchema } from "@ignfab/gpf-schema-store";
import type { GpfFeatureType } from "../../src/wfs/catalog.js";
-import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, type WfsClientLike, type TravelTimeResolver } from "../../src/proxy/execute";
+import { runGeometryFeatureQuery, runGeometryFeatureByIdQuery, runGeometryIsolineQuery, type WfsClientLike, type IsolineResolver } from "../../src/proxy/execute";
import type { CompiledRequest } from "../../src/wfs/request";
import type { WfsFeatureCollectionResponse } from "../../src/wfs/types";
import type { GpfGetFeaturesInput } from "../../src/wfs/schema";
@@ -96,18 +96,18 @@ function makeClient(overrides?: {
return { client, requests };
}
-// A resolver stub for the non-travel_time cases: `resolveTravelTime` is a required
+// A resolver stub for the non-isoline cases: `resolveIsoline` is a required
// dependency, but these queries must never invoke it — so this throws if they do,
-// turning an accidental travel_time path into a loud test failure.
-const unexpectedResolveTravelTime: TravelTimeResolver = () => {
- throw new Error("resolveTravelTime should not be called for a non-travel_time query");
+// turning an accidental isoline path into a loud test failure.
+const unexpectedResolveIsoline: IsolineResolver = () => {
+ throw new Error("resolveIsoline should not be called for a non-isoline query");
};
describe("proxy/execute · runGeometryFeatureQuery", () => {
it("returns the RAW FeatureCollection with geometry preserved", async () => {
const { client } = makeClient();
- const result = await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ const result = await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
// Geometry, crs and geometry_name must survive (opposite of the LLM trim path).
expect(result.features?.[0]?.geometry).toEqual(collectionWithGeometry.features?.[0]?.geometry);
@@ -119,7 +119,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
it("forces the geometry column into propertyName when `select` is given", async () => {
const { client, requests } = makeClient();
- await runGeometryFeatureQuery({ ...baseInput, select: ["code_insee"] }, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ await runGeometryFeatureQuery({ ...baseInput, select: ["code_insee"] }, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
const propertyName = requests[0].query.propertyName;
expect(propertyName).toBeDefined();
@@ -131,7 +131,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
it("requests WGS84 EPSG:4326 (lon/lat convention)", async () => {
const { client, requests } = makeClient();
- await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
// srsName lands on request.query, which the proxy transport serializes into the fetch URL.
expect(requests[0].query.srsName).toBe("EPSG:4326");
@@ -140,7 +140,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
it("includes the geometry column alongside all non-geometry props when no `select` is given", async () => {
const { client, requests } = makeClient();
- await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ await runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
// With no select, compileQueryParts materializes the non-geometry columns;
// the runner then appends the geometry column.
@@ -157,7 +157,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
["a missing type", { features: [] }],
])("rejects an off-contract response (%s) as a 502, not a valid layer", async (_label, badResponse) => {
const { client } = makeClient({ responses: [badResponse as unknown as WfsFeatureCollectionResponse] });
- const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
// Now a ServiceResponseError(502) — an UPSTREAM anomaly — not a plain Error (which
// server.ts would map to a misleading 500). Client still gets the generic phrase.
await expect(promise).rejects.toMatchObject({ name: "ServiceResponseError", httpStatus: 502 });
@@ -171,7 +171,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
// The client-facing message stays generic, but the internal (logged) message must
// carry the extracted upstream cause so a 200-error-body is distinguishable from a
// `{}` in the logs — the whole point of routing it through extractJsonServiceError.
- const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ const promise = runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
await expect(promise).rejects.toMatchObject({ name: "ServiceResponseError", httpStatus: 502 });
await expect(promise).rejects.toThrow(/détail amont/);
});
@@ -206,26 +206,26 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
},
};
- const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
expect(client.fetchFeatureCollection).toHaveBeenCalledTimes(2);
expect(requests[1].body).toContain("INTERSECTS");
expect(result.type).toBe("FeatureCollection");
});
- it("resolves travel_time_filter via the injected isochrone resolver", async () => {
+ it("resolves isoline_filter via the injected isochrone resolver", async () => {
const { client, requests } = makeClient();
- const resolveTravelTime = vi.fn(async () => (
+ const resolveIsoline = vi.fn(async () => (
{ type: "Polygon" as const, coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] }
));
const input: GpfGetFeaturesInput = {
...baseInput,
- travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" },
+ isoline_filter: { lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" },
};
- const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveTravelTime });
+ const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveIsoline });
- expect(resolveTravelTime).toHaveBeenCalledOnce();
+ expect(resolveIsoline).toHaveBeenCalledOnce();
// The compiled main request carries an INTERSECTS predicate built from the isochrone.
expect(requests[0].body).toContain("INTERSECTS");
expect(result.type).toBe("FeatureCollection");
@@ -262,7 +262,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
},
};
- const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime });
+ const result = await runGeometryFeatureQuery(input, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline });
// Two upstream fetches: reference-by-id, then the main query.
expect(client.fetchFeatureCollection).toHaveBeenCalledTimes(2);
@@ -289,7 +289,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
};
await expect(
- runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }),
+ runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }),
).rejects.toThrow(/catalogue embarqué est probablement désynchronisé/);
});
@@ -308,7 +308,7 @@ describe("proxy/execute · runGeometryFeatureQuery", () => {
// Must propagate as-is (same instance), so server.ts maps it to 502 — not be
// swallowed by the desync branch nor rewritten.
await expect(
- runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveTravelTime: unexpectedResolveTravelTime }),
+ runGeometryFeatureQuery(baseInput, { wfsClient: client, resolveIsoline: unexpectedResolveIsoline }),
).rejects.toBe(upstream);
});
});
@@ -412,3 +412,41 @@ describe("proxy/execute · runGeometryFeatureByIdQuery", () => {
await expect(promise).rejects.toThrow(/FeatureCollection GeoJSON exploitable/);
});
});
+
+describe("proxy/execute · runGeometryIsolineQuery", () => {
+ const isolineInput = {
+ lon: 2.35, lat: 48.85, profile: "pedestrian" as const,
+ cost_type: "time" as const, cost_value: 15,
+ };
+ const isolineGeometry = { type: "Polygon" as const, coordinates: [[[2, 48], [2.1, 48], [2, 48]]] };
+
+ it("returns the isoline as a FeatureCollection", async () => {
+ const result = await runGeometryIsolineQuery(isolineInput, {
+ getGeometry: async () => isolineGeometry,
+ });
+
+ expect(result).toEqual({
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: isolineGeometry,
+ properties: { lon: 2.35, lat: 48.85, profile: "pedestrian", cost_type: "time", cost_value: 15 },
+ }
+ ]
+ });
+ });
+
+ it("maps the layer input onto the isoline client input", async () => {
+ const calls: unknown[] = [];
+
+ await runGeometryIsolineQuery(isolineInput, {
+ getGeometry: async (input) => {
+ calls.push(input);
+ return isolineGeometry;
+ },
+ });
+
+ expect(calls).toEqual([{ lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" }]);
+ });
+});
diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts
index 5eb89af8..ced687d9 100644
--- a/test/proxy/server.test.ts
+++ b/test/proxy/server.test.ts
@@ -8,17 +8,21 @@ import { resetEnv } from "../../src/config/env";
import { PROXY_TOKEN_KIND } from "../../src/wfs/schema";
import { FeatureNotFoundError, FeatureCardinalityError } from "../../src/wfs/byId";
import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/http";
+import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation";
// Mock the proxy engine + transport so the server is exercised WITHOUT network.
const runGeometryFeatureQuery = vi.fn();
const runGeometryFeatureByIdQuery = vi.fn();
+const runGeometryIsolineQuery = vi.fn();
vi.mock("../../src/proxy/execute", () => ({
runGeometryFeatureQuery: (...args: unknown[]) => runGeometryFeatureQuery(...args),
runGeometryFeatureByIdQuery: (...args: unknown[]) => runGeometryFeatureByIdQuery(...args),
+ runGeometryIsolineQuery: (...args: unknown[]) => runGeometryIsolineQuery(...args),
}));
vi.mock("../../src/proxy/transport", () => ({
- getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveTravelTime: vi.fn() }),
+ getDefaultGeometryFeatureQueryDeps: () => ({ wfsClient: {}, resolveIsoline: vi.fn() }),
getDefaultGeometryFeatureByIdQueryDeps: () => ({ wfsClient: {} }),
+ getDefaultGeometryIsolineQueryDeps: () => ({ getGeometry: vi.fn() }),
}));
// A fixed 32-byte hex key for the test environment.
@@ -50,6 +54,17 @@ function validByIdToken() {
}, KEY);
}
+function validIsolineToken() {
+ return encodeToken({
+ kind: PROXY_TOKEN_KIND.isoline,
+ lon: 2.35,
+ lat: 48.85,
+ profile: "pedestrian",
+ cost_type: "time",
+ cost_value: 15,
+ }, KEY);
+}
+
beforeAll(async () => {
process.env.TRANSPORT_TYPE = "http";
process.env.PROXY_URL_SECRET = TEST_SECRET;
@@ -75,6 +90,7 @@ afterAll(async () => {
beforeEach(() => {
runGeometryFeatureQuery.mockReset();
runGeometryFeatureByIdQuery.mockReset();
+ runGeometryIsolineQuery.mockReset();
});
describe("proxy/server", () => {
@@ -203,6 +219,58 @@ describe("proxy/server", () => {
});
});
+ it("dispatches an isoline token to the isoline engine", async () => {
+ runGeometryIsolineQuery.mockResolvedValue(SAMPLE_COLLECTION);
+
+ const res = await request(baseUrl).get(layerPath(validIsolineToken()));
+
+ expect(res.status).toBe(200);
+ expect(res.headers["content-type"]).toContain("application/geo+json");
+ expect(JSON.parse(res.text)).toEqual(SAMPLE_COLLECTION);
+ expect(runGeometryIsolineQuery).toHaveBeenCalledOnce();
+ expect(runGeometryFeatureQuery).not.toHaveBeenCalled();
+ expect(runGeometryFeatureByIdQuery).not.toHaveBeenCalled();
+ const [input] = runGeometryIsolineQuery.mock.calls[0];
+ expect(input).toEqual({
+ lon: 2.35,
+ lat: 48.85,
+ profile: "pedestrian",
+ cost_type: "time",
+ cost_value: 15,
+ });
+ });
+
+ it.each([
+ ["minutes above the maximum", { minutes: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 }],
+ ["distance above the maximum", { minutes: NAVIGATION_ISODISTANCE_MAX_METERS + 1 }],
+ ["an unknown key", { typename: "BDTOPO_V3:batiment" }],
+ ])("400 when an isochrone token carries %s", async (_name, overrides) => {
+ const badToken = encodeToken({
+ kind: PROXY_TOKEN_KIND.isoline,
+ lon: 2.35,
+ lat: 48.85,
+ profile: "pedestrian",
+ minutes: 15,
+ ...overrides,
+ }, KEY);
+ const res = await request(baseUrl).get(layerPath(badToken));
+ expect(res.status).toBe(400);
+ expect(runGeometryIsolineQuery).not.toHaveBeenCalled();
+ });
+
+ it("400 on a query token whose isoline_filter exceeds the filter's lower time limit", async () => {
+ // 121 minutes is accepted by the isoline service but not by `isoline_filter`:
+ // the proxy must re-run the filter's own limit on the decoded payload.
+ const token = encodeToken({
+ kind: PROXY_TOKEN_KIND.query,
+ typename: "BDTOPO_V3:batiment",
+ isoline_filter: { lon: 2.35, lat: 48.85, profile: "car", cost_type: "time", cost_value: 121 },
+ }, KEY);
+ const res = await request(baseUrl).get(layerPath(token));
+ expect(res.status).toBe(400);
+ expect(runGeometryFeatureQuery).not.toHaveBeenCalled();
+ });
+
it("404 when the by-id feature is absent (FeatureNotFoundError)", async () => {
runGeometryFeatureByIdQuery.mockRejectedValue(
new FeatureNotFoundError("Le feature 'batiment.404' est introuvable dans 'BDTOPO_V3:batiment'."),
diff --git a/test/proxy/token.test.ts b/test/proxy/token.test.ts
index 71999491..0cf911d7 100644
--- a/test/proxy/token.test.ts
+++ b/test/proxy/token.test.ts
@@ -45,9 +45,9 @@ const PARAM_SHAPES: Record = {
where: [{ property: "hauteur", operator: "gt", value: "20" }],
limit: 100,
},
- travel_time: {
+ isoline: {
typename: "BDTOPO_V3:batiment",
- travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" },
+ isoline_filter: { lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" },
limit: 100,
},
};
diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts
index 102873cf..a3a9c2ae 100644
--- a/test/proxy/transport.test.ts
+++ b/test/proxy/transport.test.ts
@@ -5,10 +5,10 @@ import type { GpfGetFeaturesInput } from "../../src/wfs/schema";
// Mock ONLY the I/O boundaries, so the real proxy transport code runs:
// - fetchJSONPostWithLimit (the bounded WFS fetch, parses to JSON) — but keep the real error classes;
-// - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the travel_time leg
+// - fetchJSONGetWithLimit (the bounded isochrone fetch) — asserts the isoline leg
// goes through the SAME PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES bounds as WFS.
-// The real NavigationIsochroneClient runs (only its fetcher is mocked), so this covers
-// the previously-untested gap where the isochrone leg used unbounded fetchJSONGet.
+// The real NavigationIsolineClient runs (only its fetcher is mocked), so this covers
+// the previously-untested gap where the isoline leg used unbounded fetchJSONGet.
// - RateLimiter (assert it is invoked, without real timing).
// The parse + 502-on-bad-body now lives inside fetchJSON*WithLimit (helpers/http),
// so it is covered there; here we only assert the transport wires the right args.
@@ -35,7 +35,11 @@ vi.mock("../../src/helpers/RateLimiter", () => ({
},
}));
-import { getProxyWfsClient, resolveProxyTravelTimeGeometry } from "../../src/proxy/transport";
+import {
+ getDefaultGeometryIsolineQueryDeps,
+ getProxyWfsClient,
+ resolveProxyIsolineGeometry,
+} from "../../src/proxy/transport";
import { resetEnv } from "../../src/config/env";
import { geometryToEwkt } from "../../src/wfs/geometry";
@@ -105,24 +109,24 @@ describe("proxy/transport · buildProxyTransport (via getProxyWfsClient)", () =>
});
});
-describe("proxy/transport · resolveProxyTravelTimeGeometry", () => {
- const travelTimeInput: GpfGetFeaturesInput = {
+describe("proxy/transport · resolveProxyIsolineGeometry", () => {
+ const isolineInput: GpfGetFeaturesInput = {
typename: "BDTOPO_V3:batiment",
limit: 100,
spatial_extras: [],
- travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" },
+ isoline_filter: { lon: 2.35, lat: 48.85, cost_type: "time", cost_value: 15, profile: "pedestrian" },
};
it("resolves the isochrone through the BOUNDED fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES) and returns EWKT", async () => {
- // The real NavigationIsochroneClient runs; only its fetcher is mocked. This is
- // the regression guard: the travel_time leg must NOT use the unbounded
+ // The real NavigationIsolineClient runs; only its fetcher is mocked. This is
+ // the regression guard: the isoline leg must NOT use the unbounded
// fetchJSONGet (HTTP_TIMEOUT only) — it must go through fetchJSONGetWithLimit
- // with the SAME bounds as the WFS leg, so a 2-call travel_time stays capped.
+ // with the SAME bounds as the WFS leg, so a 2-call isoline stays capped.
fetchJSONGetWithLimit.mockResolvedValue({
geometry: { type: "Polygon", coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] },
});
- const result = await resolveProxyTravelTimeGeometry(travelTimeInput);
+ const result = await resolveProxyIsolineGeometry(isolineInput);
expect(fetchJSONGetWithLimit).toHaveBeenCalledOnce();
const [url, timeoutMs, maxBytes, label] = fetchJSONGetWithLimit.mock.calls[0];
@@ -140,13 +144,40 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => {
expect(geometryToEwkt(result)).toMatch(/^SRID=4326;POLYGON/);
});
- it("throws defensively if called without a travel_time filter", async () => {
+ it("throws defensively if called without an isoline filter", async () => {
const noFilter: GpfGetFeaturesInput = {
typename: "BDTOPO_V3:batiment",
limit: 100,
spatial_extras: [],
};
- await expect(resolveProxyTravelTimeGeometry(noFilter)).rejects.toThrow(/travel_time/);
+ await expect(resolveProxyIsolineGeometry(noFilter)).rejects.toThrow(/isoline/);
expect(fetchJSONGetWithLimit).not.toHaveBeenCalled();
});
});
+
+describe("proxy/transport · getDefaultGeometryIsolineQueryDeps", () => {
+ it("resolves the isoline through the bounded fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => {
+ const geometry = { type: "Polygon", coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] };
+ fetchJSONGetWithLimit.mockResolvedValue({ geometry });
+
+ const result = await getDefaultGeometryIsolineQueryDeps().getGeometry({
+ lon: 2.35,
+ lat: 48.85,
+ cost_type: "time",
+ cost_value: 15,
+ profile: "car",
+ });
+
+ expect(fetchJSONGetWithLimit).toHaveBeenCalledOnce();
+ const [url, timeoutMs, maxBytes, label] = fetchJSONGetWithLimit.mock.calls[0];
+ expect(url).toContain("data.geopf.fr/navigation/isochrone");
+ expect(url).toContain("point=2.35%2C48.85");
+ expect(url).toContain("costValue=15");
+ expect(url).toContain("profile=car");
+ expect(timeoutMs).toBe(10 * 1000); // PROXY_UPSTREAM_TIMEOUT (s) → ms, NOT HTTP_TIMEOUT
+ expect(maxBytes).toBe(26214400); // PROXY_MAX_RESPONSE_BYTES
+ expect(label).toBe("d'isochrone");
+ expect(rateLimit).toHaveBeenCalled();
+ expect(result).toEqual(geometry);
+ });
+});
diff --git a/test/scripts/generate-mcp-docs.test.ts b/test/scripts/generate-mcp-docs.test.ts
index bcb52e79..1bdbc168 100644
--- a/test/scripts/generate-mcp-docs.test.ts
+++ b/test/scripts/generate-mcp-docs.test.ts
@@ -65,6 +65,7 @@ describe("generate-mcp-docs helpers", () => {
{ name: "gpf_get_feature_by_id_layer" },
{ name: "gpf_count_features" },
{ name: "gpf_get_features" },
+ { name: "gpf_isoline_layer" },
{ name: "gpf_get_feature_by_id" },
{ name: "adminexpress" },
{ name: "gpf_get_features_layer" },
@@ -82,6 +83,7 @@ describe("generate-mcp-docs helpers", () => {
"gpf_count_features",
"gpf_get_feature_by_id",
"gpf_get_feature_by_id_layer",
+ "gpf_isoline_layer",
"unknown_custom_tool",
]);
});
diff --git a/test/tools/gpf-isoline-layer.test.ts b/test/tools/gpf-isoline-layer.test.ts
new file mode 100644
index 00000000..a35fae99
--- /dev/null
+++ b/test/tools/gpf-isoline-layer.test.ts
@@ -0,0 +1,269 @@
+import { vi, describe, it, expect, afterEach } from "vitest";
+
+import type { Env } from "../../src/config/env.js";
+import { decodeToken } from "../../src/proxy/token.js";
+import { NAVIGATION_ISOCHRONE_MAX_MINUTES, NAVIGATION_ISODISTANCE_MAX_METERS } from "../../src/gpf/navigation.js";
+import { PROXY_TOKEN_KIND, gpfIsolineLayerInputSchema } from "../../src/wfs/schema.js";
+import { validateStructuredContentAgainstOutputSchema } from "./helpers/outputSchema";
+
+const SECRET_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const SECRET = Buffer.from(SECRET_HEX, "hex");
+
+const mockGetEnv = vi.fn<() => Env>();
+
+vi.doMock("../../src/config/env.js", async () => {
+ const actual = await vi.importActual(
+ "../../src/config/env.js",
+ );
+ mockGetEnv.mockImplementation(actual.getEnv);
+ return {
+ ...actual,
+ getEnv: mockGetEnv,
+ };
+});
+
+const { default: GpfIsolineLayerTool } = await import(
+ "../../src/tools/GpfIsolineLayerTool.js"
+);
+
+function makeEnv(overrides: Partial): Env {
+ return {
+ TRANSPORT_TYPE: "http",
+ PROXY_URL_SECRET: SECRET,
+ PROXY_PUBLIC_BASE_URL: "https://proxy.example.test",
+ PROXY_ENDPOINT: "/api/v1/proxy",
+ ...overrides,
+ } as Env;
+}
+
+describe("Test GpfIsolineLayerTool", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ mockGetEnv.mockReset();
+ });
+
+ it("publishes the time-only cost limit without capping distance costs", () => {
+ mockGetEnv.mockReturnValue(makeEnv({}));
+ const tool = new GpfIsolineLayerTool();
+
+ const properties = tool.toolDefinition.inputSchema.properties as Record;
+ const costValueSchema = properties.cost_value as { description?: string; maximum?: number };
+
+ expect(properties).not.toHaveProperty("minutes");
+ expect(costValueSchema.description).toContain(`\`cost_type = "time"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES})`);
+ expect(costValueSchema.maximum).toBeUndefined();
+ });
+
+ it.each([
+ { cost_type: "time", cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES },
+ { cost_type: "distance", cost_value: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 },
+ ])("accepts $cost_type cost at $cost_value", async ({ cost_type, cost_value }) => {
+ mockGetEnv.mockReturnValue(makeEnv({}));
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "pedestrian",
+ cost_type,
+ cost_value,
+ },
+ },
+ });
+
+ expect(response.isError).toBeUndefined();
+ expect(response.structuredContent).toEqual({ data_url: expect.any(String) });
+ });
+
+ it("fails fast when no proxy is configured", async () => {
+ mockGetEnv.mockReturnValue(
+ makeEnv({ PROXY_URL_SECRET: undefined, PROXY_PUBLIC_BASE_URL: undefined }),
+ );
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "pedestrian",
+ cost_type: "time",
+ cost_value: 15,
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
+ }
+ expect(textContent.text).toContain("PROXY_URL_SECRET");
+ });
+
+ it("mints a data_url under stdio when a proxy is configured (gate is config, not transport)", async () => {
+ mockGetEnv.mockReturnValue(makeEnv({ TRANSPORT_TYPE: "stdio" }));
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "pedestrian",
+ cost_type: "time",
+ cost_value: 15,
+ },
+ },
+ });
+
+ expect(response.isError).toBeUndefined();
+ const payload = JSON.parse((response.content[0] as { text: string }).text);
+ expect(payload.data_url).toContain("https://proxy.example.test/api/v1/proxy/");
+ });
+
+ it("builds an opaque data_url that round-trips to the tagged isoline params", async () => {
+ mockGetEnv.mockReturnValue(makeEnv({}));
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "car",
+ cost_type: "distance",
+ cost_value: 50_000,
+ },
+ },
+ });
+
+ expect(response.isError).toBeUndefined();
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
+ }
+ const payload = JSON.parse(textContent.text);
+ expect(payload).toEqual(response.structuredContent);
+ expect(
+ validateStructuredContentAgainstOutputSchema(
+ tool.toolDefinition.outputSchema,
+ response.structuredContent,
+ ),
+ ).toBeNull();
+
+ const url = new URL(payload.data_url);
+ const token = url.pathname.slice("/api/v1/proxy/".length, -".json".length);
+ const decoded = decodeToken(token, SECRET);
+ expect(decoded).toEqual({
+ kind: PROXY_TOKEN_KIND.isoline,
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "car",
+ cost_type: "distance",
+ cost_value: 50_000,
+ });
+ });
+
+ it.each([
+ { type: "time", cost: NAVIGATION_ISOCHRONE_MAX_MINUTES + 1 },
+ { type: "distance", cost: NAVIGATION_ISODISTANCE_MAX_METERS + 1 },
+ ])("rejects a $type cost above the supported maximum ($cost)", async ({ type, cost }) => {
+ mockGetEnv.mockReturnValue(makeEnv({}));
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "pedestrian",
+ cost_type: type,
+ cost_value: cost,
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect(response.structuredContent).toBeUndefined();
+ const textContent = response.content[0];
+ if (textContent.type !== "text") {
+ throw new Error("expected text content");
+ }
+ const nom = type === "time" ? "temps" : type;
+ const units = type === "time" ? "minutes" : "mètres";
+ expect(textContent.text).toContain(`cost_value: Le coût maximal en ${nom} ne peut pas dépasser ${cost-1} ${units}.`);
+ });
+
+ it("rejects an unknown key such as kind (strict isoline surface)", async () => {
+ mockGetEnv.mockReturnValue(makeEnv({}));
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ kind: PROXY_TOKEN_KIND.query,
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "pedestrian",
+ minutes: 15,
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'kind' n'est pas reconnu.");
+ });
+
+ it("rejects a missing profile", async () => {
+ mockGetEnv.mockReturnValue(makeEnv({}));
+ const tool = new GpfIsolineLayerTool();
+
+ const response = await tool.toolCall({
+ params: {
+ name: "gpf_isoline_layer",
+ arguments: {
+ lon: 2.337306,
+ lat: 48.849319,
+ minutes: 15,
+ },
+ },
+ });
+
+ expect(response.isError).toBe(true);
+ expect((response.content[0] as { text: string }).text).toContain("Le paramètre 'profile' est requis.");
+ });
+
+ it("emits a `too_big` cost_value issue for an out-of-range distance", () => {
+ const result = gpfIsolineLayerInputSchema.safeParse({
+ lon: 2.337306,
+ lat: 48.849319,
+ profile: "pedestrian",
+ cost_type: "distance",
+ cost_value: 50_001,
+ });
+
+ expect(result.success).toBe(false);
+ if (result.success) {
+ throw new Error("expected parse failure");
+ }
+
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: "too_big",
+ path: ["cost_value"],
+ message: expect.stringContaining("ne peut pas dépasser"),
+ }),
+ ]),
+ );
+ });
+});
diff --git a/test/tools/wfs/countFeatures.test.ts b/test/tools/wfs/countFeatures.test.ts
index 54e1dc6e..a777921a 100644
--- a/test/tools/wfs/countFeatures.test.ts
+++ b/test/tools/wfs/countFeatures.test.ts
@@ -143,7 +143,7 @@ describe("Test GpfCountFeaturesTool", () => {
).toBeNull();
});
- it("should apply travel_time_filter before returning the count", async () => {
+ it("should apply isoline_filter before returning the count", async () => {
const tool = new GpfCountFeaturesTool();
mockFeatureTypes({ [COMMUNE_TYPENAME]: polygonFeatureType });
captureIsochroneRequests();
@@ -154,10 +154,11 @@ describe("Test GpfCountFeaturesTool", () => {
name: "gpf_count_features",
arguments: {
typename: "ADMINEXPRESS-COG.LATEST:commune",
- travel_time_filter: {
+ isoline_filter: {
lon: 2.337306,
lat: 48.849319,
- minutes: 5,
+ cost_type: "time",
+ cost_value: 5,
profile: "car",
},
},
diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts
index 8e82767d..4caa7c0d 100644
--- a/test/tools/wfs/getFeatures.test.ts
+++ b/test/tools/wfs/getFeatures.test.ts
@@ -241,12 +241,14 @@ describe("Test GpfGetFeaturesTool", () => {
distance_m: expect.objectContaining({ type: "number" }),
}),
});
- expect(tool.toolDefinition.inputSchema.properties?.travel_time_filter).toMatchObject({
+ expect(tool.toolDefinition.inputSchema.properties?.isoline_filter).toMatchObject({
type: "object",
properties: expect.objectContaining({
lon: expect.objectContaining({ type: "number" }),
lat: expect.objectContaining({ type: "number" }),
- minutes: expect.objectContaining({ type: "number", maximum: 120 }),
+ cost_type: expect.objectContaining({ enum: ["time", "distance"] }),
+ // The filter's lower time limit, not the isoline service's 600 minutes.
+ cost_value: expect.objectContaining({ type: "number", description: expect.stringContaining("maximum : 120)") }),
profile: expect.objectContaining({ enum: ["car", "pedestrian"] }),
}),
});
@@ -271,7 +273,7 @@ describe("Test GpfGetFeaturesTool", () => {
});
});
- it("should compile travel_time_filter into a WFS request using an isochrone geometry", async () => {
+ it("should compile isoline_filter into a WFS request using an isochrone geometry", async () => {
const tool = new GpfGetFeaturesTool();
mockFeatureTypes({ [COMMUNE_TYPENAME]: polygonFeatureType });
const isochroneUrls = captureIsochroneRequests();
@@ -282,10 +284,11 @@ describe("Test GpfGetFeaturesTool", () => {
name: "gpf_get_features",
arguments: {
typename: "ADMINEXPRESS-COG.LATEST:commune",
- travel_time_filter: {
+ isoline_filter: {
lon: 2.337306,
lat: 48.849319,
- minutes: 15,
+ cost_type: "time",
+ cost_value: 15,
profile: "pedestrian",
},
},
@@ -412,7 +415,7 @@ describe("Test GpfGetFeaturesTool", () => {
{ bbox_filter: { west: 2.1, south: 48.7, east: 2.5, north: 48.9 } },
{ dwithin_point_filter: { lon: 2.3, lat: 48.8, distance_m: 500 } },
{ intersects_feature_filter: { typename: "ADMINEXPRESS-COG.LATEST:departement", feature_id: "departement.1" } },
- { travel_time_filter: { lon: 2.3, lat: 48.8, minutes: 10, profile: "pedestrian" } },
+ { isoline_filter: { lon: 2.3, lat: 48.8, cost_type: "distance", cost_value: 10, profile: "pedestrian" } },
]) {
expect(() => gpfGetFeaturesInputSchema.parse({
typename: COMMUNE_TYPENAME,
diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts
index eda7587d..fef956bb 100644
--- a/test/wfs/queryPreparation.test.ts
+++ b/test/wfs/queryPreparation.test.ts
@@ -154,13 +154,14 @@ describe("gpfGetFeatures/queryPreparation", () => {
expect(compiled.resolvedGeometryRef).toBe(resolvedGeometryRef);
});
- it("should compile travel_time with resolved isochrone geometry", () => {
+ it("should compile isoline with resolved isochrone geometry", () => {
const compiled = compileQueryParts({
...baseInput,
- travel_time_filter: {
+ isoline_filter: {
lon: 2.3522,
lat: 48.8566,
- minutes: 15,
+ cost_type: "time",
+ cost_value: 15,
profile: "pedestrian",
},
}, wrappedFeatureType,
diff --git a/test/wfs/spatialFilter.test.ts b/test/wfs/spatialFilter.test.ts
index bc720e19..61c0f5c0 100644
--- a/test/wfs/spatialFilter.test.ts
+++ b/test/wfs/spatialFilter.test.ts
@@ -35,22 +35,24 @@ describe("getSpatialFilter", () => {
});
});
- it("should map a travel_time_filter to the compiler spatial filter", () => {
+ it("should map an isoline_filter to the compiler spatial filter", () => {
const input: GpfGetFeaturesInput = {
...baseInput,
- travel_time_filter: {
+ isoline_filter: {
lon: 2.3522,
lat: 48.8566,
- minutes: 15,
+ cost_type: "time",
+ cost_value: 15,
profile: "pedestrian",
},
};
expect(getSpatialFilter(input)).toEqual({
- operator: "travel_time",
+ operator: "isoline",
lon: 2.3522,
lat: 48.8566,
- minutes: 15,
+ cost_type: "time",
+ cost_value: 15,
profile: "pedestrian",
});
});
@@ -112,50 +114,66 @@ describe("gpfGetFeaturesInputSchema spatial filters", () => {
})).toThrow("Un seul filtre spatial est autorisé");
});
- it("should validate travel-time filters", () => {
+ it("should validate isoline filters", () => {
expect(gpfGetFeaturesInputSchema.parse({
...baseInput,
- travel_time_filter: {
+ isoline_filter: {
lon: 2.3522,
lat: 48.8566,
- minutes: 120,
+ cost_type: "time",
+ cost_value: 120,
profile: "car",
},
- }).travel_time_filter).toEqual({
+ }).isoline_filter).toEqual({
lon: 2.3522,
lat: 48.8566,
- minutes: 120,
+ cost_type: "time",
+ cost_value: 120,
profile: "car",
});
});
- it("should reject invalid travel-time filters", () => {
+ it("should reject invalid isoline filters", () => {
expect(() => gpfGetFeaturesInputSchema.parse({
...baseInput,
- travel_time_filter: {
+ isoline_filter: {
lon: 2.3522,
lat: 48.8566,
- minutes: 0,
+ cost_type: "time",
+ cost_value: 0,
profile: "pedestrian",
},
})).toThrow();
expect(() => gpfGetFeaturesInputSchema.parse({
...baseInput,
- travel_time_filter: {
+ isoline_filter: {
lon: 2.3522,
lat: 48.8566,
- minutes: 121,
+ cost_type: "time",
+ cost_value: 121, // the limit is lower for the filter than for the isoline tool
profile: "pedestrian",
},
})).toThrow();
expect(() => gpfGetFeaturesInputSchema.parse({
...baseInput,
- travel_time_filter: {
+ isoline_filter: {
lon: 2.3522,
lat: 48.8566,
- minutes: 15,
+ cost_type: "distance",
+ cost_value: 50001,
+ profile: "pedestrian",
+ },
+ })).toThrow();
+
+ expect(() => gpfGetFeaturesInputSchema.parse({
+ ...baseInput,
+ isoline_filter: {
+ lon: 2.3522,
+ lat: 48.8566,
+ cost_type: "time",
+ cost_value: 15,
profile: "bicycle",
},
})).toThrow();