diff --git a/docs/config.md b/docs/config.md
index e16eba78..ccd06692 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -28,8 +28,8 @@
| `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` 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 `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isoline_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |
+| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `isoline` et `gpf_isoline_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_isoline_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |
## Génération de `PROXY_URL_SECRET`
diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md
index 1baf6e89..f9a2c6f8 100644
--- a/docs/mcp-tools.md
+++ b/docs/mcp-tools.md
@@ -1084,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.**
@@ -1102,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. |
+| `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 `isoline_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/isodistance 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 : []. |
| `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`. |
@@ -1270,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",
@@ -1293,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": {
@@ -1308,12 +1309,6 @@ 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": [
@@ -1321,16 +1316,30 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
"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",
@@ -1382,7 +1391,7 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu
]
},
"default": [],
- "description": "É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.\n`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.\n`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`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`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`.\n`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.\n`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\nLes `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.\nSi 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.\nSinon, 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."
+ "description": "É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.\n`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.\n`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`.\n`length` est la somme des longueurs (en m) des parties linéaires de la géométrie (LineString, MultiLineString).\n`area` est la somme des surfaces (en m²) des parties surfaciques de la géométrie (Polygon, MultiPolygon).\n`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 `isoline_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`.\n`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance 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.\n`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\nLes `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.\nSi 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.\nSinon, 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."
}
},
"required": [
@@ -1419,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.
```
@@ -1428,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`. |
@@ -1595,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",
@@ -1618,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": {
@@ -1633,12 +1642,6 @@ 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": [
@@ -1646,16 +1649,30 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés,
"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",
@@ -1750,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.**
@@ -1767,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`. |
@@ -1922,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",
@@ -1945,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": {
@@ -1960,12 +1978,6 @@ 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": [
@@ -1973,16 +1985,30 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés*
"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": [
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 8ad26a4e..8b704a1a 100644
--- a/src/gpf/itinerary.ts
+++ b/src/gpf/itinerary.ts
@@ -7,8 +7,7 @@ import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE }
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.
+// Same engine as the `isoline_filter`, so that both report the same travel times.
export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE;
export const ITINERARY_PROFILES = NAVIGATION_PROFILES;
export const ITINERARY_METRICS = NAVIGATION_METRICS;
diff --git a/src/gpf/navigation.ts b/src/gpf/navigation.ts
index d95b50a8..b0b6b499 100644
--- a/src/gpf/navigation.ts
+++ b/src/gpf/navigation.ts
@@ -15,8 +15,6 @@ 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 const TRAVEL_TIME_MAX_MINUTES = 120;
-
export type NavigationProfile = typeof NAVIGATION_PROFILES[number];
export type NavigationMetric = typeof NAVIGATION_METRICS[number];
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 d71bc807..35f6f276 100644
--- a/src/proxy/execute.ts
+++ b/src/proxy/execute.ts
@@ -3,7 +3,7 @@
*
* `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
@@ -57,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 ---
@@ -161,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") {
@@ -190,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(
diff --git a/src/proxy/transport.ts b/src/proxy/transport.ts
index ba036437..53cdaccd 100644
--- a/src/proxy/transport.ts
+++ b/src/proxy/transport.ts
@@ -17,7 +17,7 @@ import { getSpatialFilter } from "../wfs/queryPreparation.js";
import type { GpfGetFeaturesInput } from "../wfs/schema.js";
import { NavigationIsolineClient } from "../gpf/navigation.js";
import type {
- TravelTimeResolver,
+ IsolineResolver,
GeometryFeatureQueryDeps,
GeometryFeatureByIdQueryDeps,
GeometryIsolineQueryDeps,
@@ -92,7 +92,7 @@ let cachedProxyIsolineClient: NavigationIsolineClient | undefined;
* (`PROXY_UPSTREAM_TIMEOUT` + `PROXY_MAX_RESPONSE_BYTES`) and its own
* `GPF_NAVIGATION_PROXY` rate limiter — NOT the default `navigationIsolineClient`
* singleton, which uses the unbounded `HTTP_TIMEOUT`-only `fetchJSONGet`. This
- * keeps both upstream legs of a `travel_time` layer request under the same bounds,
+ * keeps both upstream legs of an `isoline` layer request under the same bounds,
* so its worst case matches `intersects_feature` (2 × PROXY_UPSTREAM_TIMEOUT).
* Lazily built so the bounds are read from a fully-parsed environment.
*/
@@ -104,34 +104,30 @@ function getProxyIsolineClient(): NavigationIsolineClient {
return cachedProxyIsolineClient;
}
-// --- Reference-geometry resolver (travel_time / isoline) ---
+// --- Reference-geometry resolver ---
/**
- * Reference-geometry resolver for the `travel_time` spatial filter: turns 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 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`.");
}
- const { operator, minutes, ...parameters } = spatialFilter;
+ const { operator, ...parameters } = spatialFilter;
- return await getProxyIsolineClient().getIsoline({
- ...parameters,
- cost_type: "time",
- cost_value: minutes,
- });
+ return await getProxyIsolineClient().getIsoline(parameters);
};
// --- Default Engine Dependencies ---
@@ -146,7 +142,7 @@ export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async (
export function getDefaultGeometryFeatureQueryDeps(): GeometryFeatureQueryDeps {
return {
wfsClient: getProxyWfsClient(),
- resolveTravelTime: resolveProxyTravelTimeGeometry,
+ resolveIsoline: resolveProxyIsolineGeometry,
};
}
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/wfs/features.ts b/src/wfs/features.ts
index 559a85bb..6f11c4ae 100644
--- a/src/wfs/features.ts
+++ b/src/wfs/features.ts
@@ -96,26 +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;
}
- const { operator, minutes, ...parameters } = spatialFilter;
+ const { operator, ...parameters } = spatialFilter;
- return await navigationIsolineClient.getIsoline({
- ...parameters,
- cost_type: "time",
- cost_value: minutes,
- });
+ return await navigationIsolineClient.getIsoline(parameters);
}
/**
@@ -132,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 49b8b015..31058106 100644
--- a/src/wfs/schema.ts
+++ b/src/wfs/schema.ts
@@ -16,7 +16,6 @@ import {
NAVIGATION_ISOCHRONE_MAX_MINUTES,
NAVIGATION_ISODISTANCE_MAX_METERS,
type NavigationMetric,
- TRAVEL_TIME_MAX_MINUTES,
} from "../gpf/navigation.js";
// --- Shared Constants ---
@@ -102,14 +101,7 @@ const navigationProfileSchema = z
.enum(NAVIGATION_PROFILES)
.describe("Mode de déplacement utilisé pour calculer l'isochrone ou l'isodistance : `car` ou `pedestrian`.");
-const travelTimeMinutesSchema = z
- .number()
- .finite()
- .positive()
- .max(TRAVEL_TIME_MAX_MINUTES)
- .describe(`Temps de trajet maximal en minutes. Maximum : ${TRAVEL_TIME_MAX_MINUTES}.`);
-
-// Departure point of an isochrone. Flat `lon`/`lat`, exactly like every spatial
+// 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({
@@ -117,43 +109,63 @@ const isolinePointSchema = z.object({
lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."),
}).strict();
-const travelTimeFilterSchema = isolinePointSchema.extend({
- minutes: travelTimeMinutesSchema,
- profile: navigationProfileSchema,
-}).strict().describe("Filtre les objets situés dans une zone atteignable en un temps donné depuis un point.");
-
const navigationMetricsSchema = z
.enum(NAVIGATION_METRICS)
.describe("Type de coût utilisé : `time` pour une isochrone, `distance` pour une isodistance.");
-const isolineCostValueSchema = z
- .number()
- .finite()
- .positive()
- .describe(`Valeur du coût maximal. Interprétée en minutes si \`cost_type = \"time\"\` (maximum : ${NAVIGATION_ISOCHRONE_MAX_MINUTES}), et en mètres si \`cost_type = \"distance\"\` (maximum : ${NAVIGATION_ISODISTANCE_MAX_METERS}).`);
+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: Record = {
+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" },
};
-function assertIsolineCostValue(input: { cost_type: NavigationMetric; cost_value: number }, ctx: z.RefinementCtx) {
- const { max, name, unit } = ISOLINE_COST_LIMITS[input.cost_type];
+// Lower time limit for `isoline_filter`, for performance.
+const ISOLINE_FILTER_MAX_TIME_MINUTES = 120;
- 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 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: NavigationMetric; 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 ---
const gpfTypenameInputSchema = z.object({
@@ -181,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 =
@@ -286,8 +298,8 @@ const gpfGetFeaturesGeometryExtraInputSchema = z.object({
.describe(buildSpatialExtrasDescription(
"chaque objet",
GPF_SPATIAL_EXTRAS_DOCNAMES,
- "`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`.\n"+
- "`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.\n"+
+ "`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 `isoline_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`.\n"+
+ "`intersection_area` est l'aire (en m²) de la partie de l'objet renvoyé située dans le filtre spatial (boîte, disque, isochrone/isodistance 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.\n"+
"`distance_to_filter_center` et `intersection_area` exigent un filtre spatial.\n"+
"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."
)),
@@ -482,14 +494,11 @@ export const gpfGetFeatureByIdLayerPublishedInputSchema = generatePublishedInput
// --- `gpf_isoline_layer` (proxy) ---
-export const gpfIsolineLayerInputObjectSchema = isolinePointSchema.extend({
- profile: navigationProfileSchema,
- cost_type: navigationMetricsSchema,
- cost_value: isolineCostValueSchema,
-}).strict();
+export const gpfIsolineLayerInputObjectSchema = isolinePointSchema
+ .merge(buildIsolineCostSchema(ISOLINE_COST_LIMITS));
export const gpfIsolineLayerInputSchema = gpfIsolineLayerInputObjectSchema
- .superRefine(assertIsolineCostValue);
+ .superRefine(assertIsolineCostValue(ISOLINE_COST_LIMITS));
export type GpfIsolineLayerInput = z.infer;
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 8c3bfb0b..9c1852d6 100644
--- a/test/gpf/navigation.test.ts
+++ b/test/gpf/navigation.test.ts
@@ -5,7 +5,7 @@ import { RateLimiter } from "../../src/helpers/RateLimiter.js";
import { ServiceResponseError } from "../../src/helpers/http.js";
describe("NavigationIsolineClient", () => {
- it("should build a Valhalla travel-time isoline request and return its GeoJSON geometry", async () => {
+ it("should build a Valhalla isochrone request and return its GeoJSON geometry", async () => {
const urls: string[] = [];
const client = new NavigationIsolineClient(
new RateLimiter({ name: "test", maxCalls: 100, period: 1 }),
diff --git a/test/proxy/execute.test.ts b/test/proxy/execute.test.ts
index 7a2c0290..bfc69de7 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, runGeometryIsolineQuery, 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);
});
});
diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts
index 41b19b03..3a127bc2 100644
--- a/test/proxy/server.test.ts
+++ b/test/proxy/server.test.ts
@@ -20,7 +20,7 @@ vi.mock("../../src/proxy/execute", () => ({
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() }),
}));
@@ -259,6 +259,19 @@ describe("proxy/server", () => {
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 2aef5981..a3a9c2ae 100644
--- a/test/proxy/transport.test.ts
+++ b/test/proxy/transport.test.ts
@@ -5,7 +5,7 @@ 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 NavigationIsolineClient runs (only its fetcher is mocked), so this covers
// the previously-untested gap where the isoline leg used unbounded fetchJSONGet.
@@ -38,7 +38,7 @@ vi.mock("../../src/helpers/RateLimiter", () => ({
import {
getDefaultGeometryIsolineQueryDeps,
getProxyWfsClient,
- resolveProxyTravelTimeGeometry,
+ resolveProxyIsolineGeometry,
} from "../../src/proxy/transport";
import { resetEnv } from "../../src/config/env";
import { geometryToEwkt } from "../../src/wfs/geometry";
@@ -109,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 NavigationIsolineClient runs; only its fetcher is mocked. This is
- // the regression guard: the travel_time leg must NOT use the unbounded
+ // 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];
@@ -144,13 +144,13 @@ 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();
});
});
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();