diff --git a/api/apps/geoprocessing/src/modules/cost-surface/cost-surface.controller.ts b/api/apps/geoprocessing/src/modules/cost-surface/cost-surface.controller.ts new file mode 100644 index 0000000000..6280577b9c --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/cost-surface.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + Header, + Logger, + Param, + Query, + Res, +} from '@nestjs/common'; +import { apiGlobalPrefixes } from '@marxan-geoprocessing/api.config'; +import { + ApiBadRequestResponse, + ApiOperation, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { BBox } from 'geojson'; + +import { Response } from 'express'; +import { setTileResponseHeadersForSuccessfulRequests } from '@marxan/tiles'; +import { CostSurfaceService, CostSurfaceTileRequest } from "@marxan-geoprocessing/modules/cost-surface/cost-surface.service"; + +@Controller(`${apiGlobalPrefixes.v1}/cost-surfaces`) +export class FeaturesController { + private readonly logger: Logger = new Logger(FeaturesController.name); + + constructor(public service: CostSurfaceService) {} + + @ApiOperation({ + description: 'Get tile for a cost surface by id.', + }) + @ApiParam({ + name: 'z', + description: 'The zoom level ranging from 0 - 20', + type: Number, + required: true, + }) + @ApiParam({ + name: 'x', + description: 'The tile x offset on Mercator Projection', + type: Number, + required: true, + }) + @ApiParam({ + name: 'y', + description: 'The tile y offset on Mercator Projection', + type: Number, + required: true, + }) + @ApiParam({ + name: 'id', + description: 'Specific id of the cost surface', + type: String, + required: true, + }) + @ApiQuery({ + name: 'bbox', + description: 'Bounding box of the project', + type: [Number], + required: false, + example: [-1, 40, 1, 42], + }) + @Get(':projectId/cost-surface/:costSurfaceId/preview/tiles/:z/:x/:y.mvt') + @ApiBadRequestResponse() + async getTile( + @Param() TileSpecification: CostSurfaceTileRequest, + @Res() response: Response, + ): Promise { + const tile: Buffer = await this.service.findTile( + TileSpecification + ); + setTileResponseHeadersForSuccessfulRequests(response); + return response.send(tile); + } +} diff --git a/api/apps/geoprocessing/src/modules/cost-surface/cost-surface.service.ts b/api/apps/geoprocessing/src/modules/cost-surface/cost-surface.service.ts new file mode 100644 index 0000000000..d936253c55 --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/cost-surface.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { TileService } from '@marxan-geoprocessing/modules/tile/tile.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, Repository } from "typeorm"; +import { GeoFeatureGeometry } from '@marxan/geofeatures'; +import { IsArray, IsNumber, IsString, IsOptional, IsDefined } from "class-validator"; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { BBox } from 'geojson'; +import { antimeridianBbox, nominatim2bbox } from '@marxan/utils/geo'; + +import { TileRequest } from '@marxan/tiles'; +import { ProtectedAreaTileRequest } from "@marxan-geoprocessing/modules/protected-areas/protected-area-tile-request"; +import { QueryResult } from "pg"; +import { TileSpecification } from "@marxan-geoprocessing/modules/features/features.service"; +import { CostSurfacePuDataEntity } from "@marxan/cost-surfaces"; +export class CostSurfaceTileRequest extends TileRequest { + @ApiProperty() + @IsString() + projectId!: string; + + @ApiProperty() + @IsString() + costSurfaceId!: string; +} + +export class CostSurfaceFilters { + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + @Transform((value: string): BBox => JSON.parse(value)) + bbox?: BBox; +} + + +@Injectable() +export class CostSurfaceService { + private readonly logger: Logger = new Logger(CostSurfaceService.name); + + constructor( + @InjectRepository(GeoFeatureGeometry) + private readonly costSurfaceDataRepository: Repository, + private readonly tileService: TileService, + ) {} + + + buildFeaturesWhereQuery(id: string, bbox?: BBox): string { + let whereQuery = `cost_surface_id = '${id}'`; + + if (bbox) { + const { westBbox, eastBbox } = antimeridianBbox(nominatim2bbox(bbox)); + whereQuery += `AND + (st_intersects( + st_intersection(st_makeenvelope(${eastBbox}, 4326), + ST_MakeEnvelope(0, -90, 180, 90, 4326)), + the_geom + ) or st_intersects( + st_intersection(st_makeenvelope(${westBbox}, 4326), + ST_MakeEnvelope(-180, -90, 0, 90, 4326)), + the_geom + ))`; + } + return whereQuery; + } + + public findTile( + tileSpecification: CostSurfaceTileRequest, + bbox?: BBox, + ): Promise { + const { z, x, y, costSurfaceId } = tileSpecification; + const simplificationLevel = 360 / (Math.pow(2, z + 1) * 100); + const attributes = 'cost_surface_id, properties'; + const table = `(select ST_RemoveRepeatedPoints((st_dump(the_geom)).geom, ${simplificationLevel}) as the_geom, + (coalesce(properties,'{}'::jsonb) || jsonb_build_object('cost', cost)) as properties, + cost_surface_id + from "${this.costSurfaceDataRepository.metadata.tableName}") + inner join projects_pu on project_pu.id = cost_surface_pu_dat.projects_pu_id`; + + const customQuery = this.buildFeaturesWhereQuery(costSurfaceId, bbox); + return this.tileService.getTile({ + z, + x, + y, + table, + customQuery, + attributes, + }); + } +}