diff --git a/docs/v1/spec.yaml b/docs/v1/spec.yaml index a432145..dc67ed7 100644 --- a/docs/v1/spec.yaml +++ b/docs/v1/spec.yaml @@ -110,6 +110,45 @@ paths: 400: $ref: '#/components/responses/BadRequest' + /v1/tile-clusterer: + get: + summary: | + Returns geo features whose coordinates are within a rectangle that is uniquely defined by its x,y and z tile coordinates + But reduce all points to one point with count of points in cluster + + parameters: + - in: query + name: x + schema: + type: integer + required: true + example: 10 + + - in: query + name: y + schema: + type: integer + required: true + example: 20 + + - in: query + name: z + schema: + type: integer + required: true + example: 5 + + responses: + 200: + description: Features list. + content: + application/json: + schema: + $ref: '#/components/schemas/FeaturesWithBounds' + + 400: + $ref: '#/components/responses/BadRequest' + /version: get: description: Application Version diff --git a/src/app/v1/index.ts b/src/app/v1/index.ts index 1a314d1..6749b2c 100644 --- a/src/app/v1/index.ts +++ b/src/app/v1/index.ts @@ -2,9 +2,11 @@ import {Router} from 'express'; import {asyncMiddleware} from '../lib/async-middlware'; import {loadByTile} from './load-by-tile'; import {loadByBBox} from './load-by-bbox'; +import {loadByTileClusterer} from './load-by-tile-cluster'; import {apiDocs} from '../middleware/api-docs'; export const router = Router({mergeParams: true}) .use('/api_docs', apiDocs) .get('/tile', asyncMiddleware(loadByTile)) + .get('/tile-clusterer', asyncMiddleware(loadByTileClusterer)) .get('/bbox', asyncMiddleware(loadByBBox)); diff --git a/src/app/v1/load-by-tile-cluster.ts b/src/app/v1/load-by-tile-cluster.ts new file mode 100644 index 0000000..0688f9e --- /dev/null +++ b/src/app/v1/load-by-tile-cluster.ts @@ -0,0 +1,59 @@ +import {Request, Response} from 'express'; +import {z} from 'zod'; +import * as Boom from '@hapi/boom'; +import {formatZodError, numericString} from '../lib/zod'; +import {Bounds} from '../lib/geo'; +import {fromWorldCoordinates, tileToWorld} from '../lib/projection/projection'; + +const getTileRequestSchema = z + .object({ + limit: numericString(z.number().int().min(100).max(10000).default(1000)), + x: numericString(z.number().int()), + y: numericString(z.number().int()), + z: numericString(z.number().int()) + }) + .strict(); + +export async function loadByTileClusterer(req: Request, res: Response): Promise { + const validationResult = getTileRequestSchema.safeParse(req.query); + if (!validationResult.success) { + throw Boom.badRequest(formatZodError(validationResult.error)); + } + + const {x: tx, y: ty, z: tz, limit} = validationResult.data; + + const coordinates: Bounds = tileToWorld(tx, ty, tz).map(fromWorldCoordinates) as Bounds; + const result = await req.dataProvider.getFeaturesByBBox(coordinates, limit); + const {leftBottom, rightTop} = result.features.reduce( + (mm, point) => { + const [lng, lat] = point.geometry.coordinates; + return { + leftBottom: [Math.min(mm.leftBottom[0], lng), Math.max(mm.leftBottom[1], lat)], + rightTop: [Math.max(mm.rightTop[0], lng), Math.min(mm.rightTop[1], lat)] + }; + }, + {leftBottom: [Infinity, -Infinity], rightTop: [-Infinity, Infinity]} + ); + + res.send({ + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + (leftBottom[0] + rightTop[0]) / 2, + (leftBottom[1] + rightTop[1]) / 2 + ] + }, + properties: { + count: result.total + } + } + ], + + total: result.total, + minMax: [leftBottom, rightTop], + bounds: coordinates + }); +} diff --git a/src/tests/v1/api.test.ts b/src/tests/v1/api.test.ts index 293daa7..259dca8 100644 --- a/src/tests/v1/api.test.ts +++ b/src/tests/v1/api.test.ts @@ -121,5 +121,30 @@ describe('/v1', () => { }); }); }); + + describe('Check tile clusterer', () => { + it('should return 1 point inside tile', async () => { + const res = await testServer.request('/v1/tile-clusterer?x=10&y=11&z=5', { + json: true + }); + expect(res.statusCode).toEqual(200); + + const result = res.body as {features: Feature[]; bounds: Bounds; minMax: Bounds}; + expect(result.features.length).toEqual(1); + expect(result.bounds).toEqual([ + [-67.5, 48.92249926375823], + [-56.25, 40.97989806962013] + ]); + + expect(result.minMax).toEqual([ + [-67.30010370199994, 47.960976981000044], + [-56.3153175459999, 43.45754888200008] + ]); + + expect(result.features[0].geometry.coordinates).toEqual([ + -61.807710623999924, 45.709262931500064 + ]); + }); + }); }); });