diff --git a/README.md b/README.md index 07beae4..aef4c2b 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ mcp add tool gpf_wmts_layers * [@ignfab/gpf-schema-store](https://www.npmjs.com/package/@ignfab/gpf-schema-store) pour le **catalogue de schémas embarqué** utilisé par les outils d'exploration WFS. * [MiniSearch](https://github.com/lucaong/minisearch) pour la **recherche par mot clé** utilisée dans `@ignfab/gpf-schema-store`. * [jsts](https://bjornharrtell.github.io/jsts/) pour les **traitements géométriques** (ex : tri des réponses par distance au point recherché). +* [turfjs/distance](https://turfjs.org/docs/api/distance) pour les **calculs de distance** avec la [formule de Haversine](https://en.wikipedia.org/wiki/Haversine_formula). ## Licence diff --git a/package-lock.json b/package-lock.json index c5a4575..4c7ed85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@ignfab/gpf-schema-store": "^0.1.3", "@rgrove/parse-xml": "^4.2.0", + "@turf/distance": "^7.3.4", + "@turf/helpers": "^7.3.4", "https-proxy-agent": "^7.0.6", "jsts": "^2.12.1", "lodash": "^4.17.21", @@ -1339,6 +1341,48 @@ "dev": true, "license": "MIT" }, + "node_modules/@turf/distance": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.3.4.tgz", + "integrity": "sha512-9drWgd46uHPPyzgrcRQLgSvdS/SjVlQ6ZIBoRQagS5P2kSjUbcOXHIMeOSPwfxwlKhEtobLyr+IiR2ns1TfF8w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", + "@turf/invariant": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz", + "integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.4.tgz", + "integrity": "sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1416,7 +1460,6 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -6689,9 +6732,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-detect": { "version": "4.0.8", diff --git a/package.json b/package.json index cf96226..3de280c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "dependencies": { "@ignfab/gpf-schema-store": "^0.1.3", "@rgrove/parse-xml": "^4.2.0", + "@turf/distance": "^7.3.4", + "@turf/helpers": "^7.3.4", "https-proxy-agent": "^7.0.6", "jsts": "^2.12.1", "lodash": "^4.17.21", diff --git a/src/gpf/urbanisme.js b/src/gpf/urbanisme.js index 978bf37..c2474b9 100644 --- a/src/gpf/urbanisme.js +++ b/src/gpf/urbanisme.js @@ -58,7 +58,7 @@ export async function getUrbanisme(lon, lat, fetcher) { return features.map((feature) => { const item = { ...mapWfsFeature(feature, URBANISME_TYPES), - distance: distance(sourceGeom, feature.geometry) * 1000.0, + distance: distance(sourceGeom, feature.geometry), }; return sanitizeUrbanismeItem(item); }); @@ -89,6 +89,6 @@ export async function getAssiettesServitudes(lon, lat, fetcher) { const features = await fetchWfsFeatures(ASSIETTES_SUP_TYPES, cql_filter, 'Urbanisme', fetcher); return features.map((feature) => ({ ...mapWfsFeature(feature, ASSIETTES_SUP_TYPES), - distance: distance(sourceGeom, feature.geometry) * 1000.0, + distance: distance(sourceGeom, feature.geometry), })); } diff --git a/src/helpers/distance.js b/src/helpers/distance.js index fc3afe8..2e6944c 100644 --- a/src/helpers/distance.js +++ b/src/helpers/distance.js @@ -1,10 +1,15 @@ import GeoJSONReader from 'jsts/org/locationtech/jts/io/GeoJSONReader.js' import { DistanceOp } from 'jsts/org/locationtech/jts/operation/distance.js' +import turfDistance from '@turf/distance' +import {point as turfPoint} from '@turf/helpers' + /** - * Compute approximative distance in km between gA and gB. + * Compute approximative distance in meters between gA and gB. + * + * TODO: replace the lon/lat planar nearest-point step with a geodesic geometry distance. * - * @param {object} gA GeoJSON Point + * @param {object} gA GeoJSON Geometry * @param {object} gB GeoJSON Geometry */ export default function distance(gA, gB) { @@ -12,8 +17,23 @@ export default function distance(gA, gB) { const a = geojsonReader.read(gA); const b = geojsonReader.read(gB); - // converts to kilometers assuming earth is a sphere - const distanceInDegree = DistanceOp.distance(a, b); - return 6480.0 * ( distanceInDegree * 2.0 * Math.PI / 360.0 ) ; -} + /* + * Get the 2 nearest points between a and b + * + * Note that it will project according to longitude and latitude axis, + * so it is not really accurate, but it is a good approximation + */ + const nearestPoints = DistanceOp.nearestPoints(a, b); + if ( nearestPoints.length !== 2 ) { + throw new Error('DistanceOp.nearestPoints should return 2 points'); + } + /* + * haversine distance between the 2 nearest points (see https://turfjs.org/docs/api/distance) + */ + return turfDistance( + turfPoint([nearestPoints[0].x, nearestPoints[0].y]), + turfPoint([nearestPoints[1].x, nearestPoints[1].y]), + { units: 'meters' } + ); +} diff --git a/src/tools/AssietteSupTool.ts b/src/tools/AssietteSupTool.ts index ceebbba..c52d62e 100644 --- a/src/tools/AssietteSupTool.ts +++ b/src/tools/AssietteSupTool.ts @@ -18,7 +18,7 @@ const assietteSupResultSchema = z id: z.string().describe("L'identifiant de l'assiette."), bbox: z.array(z.number()).describe("La boîte englobante de l'assiette.").optional(), feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.").optional(), - distance: z.number().describe("La distance entre le point demandé et l'assiette retenue."), + distance: z.number().describe("La distance en mètres entre le point demandé et l'assiette retenue."), }) .catchall(z.unknown()); diff --git a/src/tools/CadastreTool.ts b/src/tools/CadastreTool.ts index 7910f1d..1c6d705 100644 --- a/src/tools/CadastreTool.ts +++ b/src/tools/CadastreTool.ts @@ -18,7 +18,7 @@ const cadastreResultSchema = z id: z.string().describe("L'identifiant de l'objet cadastral."), bbox: z.array(z.number()).describe("La boîte englobante de l'objet cadastral.").optional(), feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`."), - distance: z.number().describe("La distance entre le point demandé et l'objet cadastral retenu."), + distance: z.number().describe("La distance en mètres entre le point demandé et l'objet cadastral retenu."), source: z.string().describe("La source des données cadastrales."), }) .catchall(z.unknown()); @@ -27,11 +27,17 @@ const cadastreOutputSchema = z.object({ results: z.array(cadastreResultSchema).describe("La liste des objets cadastraux les plus proches du point demandé."), }); +const CADASTRE_TOOL_DESCRIPTION = [ + `Renvoie, pour un point donné par sa longitude et sa latitude, la liste des objets cadastraux (${PARCELLAIRE_EXPRESS_TYPES.join(', ')}) les plus proches, avec leurs informations associées.`, + `Les résultats sont retournés au plus une fois par type lorsqu'ils sont disponibles et incluent un \`feature_ref\` WFS réutilisable.`, + 'La distance de recherche est fixée à 10 mètres.' +] + class CadastreTool extends MCPTool { name = "cadastre"; title = "Informations cadastrales"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; - description = `Renvoie, pour un point donné par sa longitude et sa latitude, la liste des objets cadastraux (${PARCELLAIRE_EXPRESS_TYPES.join(', ')}) les plus proches, avec leurs informations associées. Les résultats sont retournés au plus une fois par type lorsqu'ils sont disponibles et incluent un \`feature_ref\` WFS réutilisable. (source : ${PARCELLAIRE_EXPRESS_SOURCE}).`; + description = CADASTRE_TOOL_DESCRIPTION.join("\n"); protected outputSchemaShape = cadastreOutputSchema; schema = cadastreInputSchema; diff --git a/src/tools/UrbanismeTool.ts b/src/tools/UrbanismeTool.ts index fb7b8f4..0d67385 100644 --- a/src/tools/UrbanismeTool.ts +++ b/src/tools/UrbanismeTool.ts @@ -18,7 +18,7 @@ const urbanismeResultSchema = z id: z.string().describe("L'identifiant de l'objet d'urbanisme."), bbox: z.array(z.number()).describe("La boîte englobante de l'objet d'urbanisme.").optional(), feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.").optional(), - distance: z.number().describe("La distance entre le point demandé et l'objet d'urbanisme retenu."), + distance: z.number().describe("La distance en mètres entre le point demandé et l'objet d'urbanisme retenu."), }) .catchall(z.unknown()); diff --git a/test/helpers/distance.test.ts b/test/helpers/distance.test.ts index b723b64..00097c8 100644 --- a/test/helpers/distance.test.ts +++ b/test/helpers/distance.test.ts @@ -4,15 +4,34 @@ import {paris, marseille, besancon, parisMarseille} from '../samples'; describe("Test distance",() => { describe("Test distance(Point,Point)", () => { - it("should return ~718.8km from Paris to Marseille",() => { + it("should return 662489.3m from Paris to Marseille",() => { const result = distance(paris,marseille); - expect(result).toBeCloseTo(718.8,1); + expect(result).toBeCloseTo(662489.3,1); }); }); describe("Test distance(Point,LineString)", () => { - it("should return ~276.7km from Besançon to [Paris,Marseille]",() => { + it("should return 209731.2m from Besançon to [Paris,Marseille]",() => { const result = distance(besancon,parisMarseille); - expect(result).toBeCloseTo(276.7,1); + expect(result).toBeCloseTo(209731.2,1); + }); + }); + + describe("Test distance(Point,Polygon)", () => { + it("should return 0m from Paris point to a polygon containing Paris",() => { + const polygonContainingParis = { + "type": "Polygon", + "coordinates": [ + [ + [2.0, 48.0], + [3.0, 48.0], + [3.0, 49.0], + [2.0, 49.0], + [2.0, 48.0] + ] + ] + }; + const result = distance(paris, polygonContainingParis); + expect(result).toEqual(0); }); }); });