Skip to content

Commit

Permalink
feat: planning area grid custom piece exporter (#878)
Browse files Browse the repository at this point in the history
* feat: planning area grid custom piece exporter

* feat: add size property to exported planning area custom grid

* fix: create one stream per file

* ref: grid file format

Co-authored-by: Ángel Higuera Vaquero <angelhiguera@acidtango.com>
  • Loading branch information
aciddaute and angelhigueraacid authored Mar 2, 2022
1 parent 739f6d6 commit dd75809
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 38 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ClonePiece, ResourceId, ResourceKind } from '@marxan/cloning/domain';
import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
Expand Down Expand Up @@ -47,16 +48,25 @@ export class ExportResourcePiecesAdapter implements ExportResourcePieces {
}

const customPlanningArea = Boolean(project.planningAreaGeometryId);
const customGrid =
customPlanningArea &&
project.planningUnitGridShape === PlanningUnitGridShape.FromShapefile;

return [
const components: ExportComponent[] = [
ExportComponent.newOne(id, ClonePiece.ProjectMetadata),
ExportComponent.newOne(id, ClonePiece.ExportConfig),
customPlanningArea
? ExportComponent.newOne(id, ClonePiece.PlanningAreaCustom)
: ExportComponent.newOne(id, ClonePiece.PlanningAreaGAdm),
// ExportComponent.newOne(id, ClonePiece.PlanningAreaGridCustom),
...scenarioPieces.flat(),
];

if (customGrid)
components.push(
ExportComponent.newOne(id, ClonePiece.PlanningAreaGridCustom),
);

return components;
}

private async resolveForScenario(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,95 @@ import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig';
import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning';
import { ResourceKind } from '@marxan/cloning/domain';
import { ClonePieceUrisResolver } from '@marxan/cloning/infrastructure/clone-piece-data';
import { planningAreaCustomGridGeoJSONRelativePath } from '@marxan/cloning/infrastructure/clone-piece-data/planning-area-grid-custom';
import {
planningAreaCustomGridGeoJSONRelativePath,
PlanningAreaGridCustomGeoJsonTransform,
PlanningAreaGridCustomTransform,
} from '@marxan/cloning/infrastructure/clone-piece-data/planning-area-grid-custom';
import { FileRepository } from '@marxan/files-repository';
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { isLeft } from 'fp-ts/Either';
import { GeoJSON } from 'geojson';
import { Readable } from 'stream';
import { BBox } from 'geojson';
import { EntityManager } from 'typeorm';
import {
ExportPieceProcessor,
PieceExportProvider,
} from '../pieces/export-piece-processor';

interface ProjectSelectResult {
id: string;
bbox: BBox;
}

@Injectable()
@PieceExportProvider()
export class PlanningAreaCustomGridPieceExporter
implements ExportPieceProcessor {
constructor(
private readonly fileRepository: FileRepository,
@InjectEntityManager(geoprocessingConnections.apiDB)
private readonly entityManager: EntityManager,
private readonly apiEntityManager: EntityManager,
@InjectEntityManager(geoprocessingConnections.default)
private readonly geoprocessingEntityManager: EntityManager,
) {}

isSupported(piece: ClonePiece): boolean {
// TODO resource kind filtering
return piece === ClonePiece.PlanningAreaGridCustom;
isSupported(piece: ClonePiece, kind: ResourceKind): boolean {
return (
piece === ClonePiece.PlanningAreaGridCustom &&
kind === ResourceKind.Project
);
}

async run(input: ExportJobInput): Promise<ExportJobOutput> {
if (input.resourceKind === ResourceKind.Scenario) {
throw new Error(`Exporting scenario is not yet supported.`);
}
const [project]: [ProjectSelectResult] = await this.apiEntityManager.query(
`
SELECT id, bbox
FROM projects
WHERE id = $1
`,
[input.resourceId],
);

const metadata = JSON.stringify({
shape: 'square',
areaKm2: 4000,
bbox: [],
file: planningAreaCustomGridGeoJSONRelativePath,
});
const qb = this.geoprocessingEntityManager.createQueryBuilder();
const gridStream = await qb
// TODO puid should be obtained in a proper way
.select('ST_AsEWKB(the_geom) as ewkb, row_number() over () as puid')
.from('planning_units_geom', 'pug')
.where('project_id = :projectId', { projectId: project.id })
.stream();

const geoJson: GeoJSON = {
bbox: [0, 0, 0, 0, 0, 0],
coordinates: [],
type: 'MultiPolygon',
};
const gridFileTransform = new PlanningAreaGridCustomTransform();

const planningAreaGeoJson = await this.fileRepository.save(
Readable.from(JSON.stringify(geoJson)),
);
gridStream.pipe(gridFileTransform);

const outputFile = await this.fileRepository.save(
Readable.from(metadata),
`json`,
);

if (isLeft(outputFile)) {
const gridFile = await this.fileRepository.save(gridFileTransform);
if (isLeft(gridFile)) {
throw new Error(
`${PlanningAreaCustomGridPieceExporter.name} - Project Custom PA - couldn't save file - ${outputFile.left.description}`,
`${PlanningAreaCustomGridPieceExporter.name} - Project Custom PA - couldn't save file - ${gridFile.left.description}`,
);
}

if (isLeft(planningAreaGeoJson)) {
const geoJsonQb = this.geoprocessingEntityManager.createQueryBuilder();
const geoJsonStream = await geoJsonQb
.select('ST_AsGeoJSON(the_geom) as geojson')
.from('planning_units_geom', 'pug')
.where('project_id = :projectId', { projectId: project.id })
.stream();

const geojsonFileTransform = new PlanningAreaGridCustomGeoJsonTransform(
project.bbox,
);
geoJsonStream.pipe(geojsonFileTransform);

const gridGeoJson = await this.fileRepository.save(
geojsonFileTransform,
'json',
);

if (isLeft(gridGeoJson)) {
throw new Error(
`${PlanningAreaCustomGridPieceExporter.name} - Project Custom PA - couldn't save file - ${planningAreaGeoJson.left.description}`,
`${PlanningAreaCustomGridPieceExporter.name} - Project Custom PA - couldn't save file - ${gridGeoJson.left.description}`,
);
}

Expand All @@ -74,10 +99,10 @@ export class PlanningAreaCustomGridPieceExporter
uris: [
...ClonePieceUrisResolver.resolveFor(
ClonePiece.PlanningAreaGridCustom,
outputFile.right,
gridFile.right,
),
{
uri: planningAreaGeoJson.right,
uri: gridGeoJson.right,
relativePath: planningAreaCustomGridGeoJSONRelativePath,
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,69 @@
export const planningAreaCustomGridRelativePath = 'project-grid.json';
import { Polygon } from 'geojson';
import { Transform, TransformCallback } from 'stream';

export const planningAreaCustomGridRelativePath = 'project-grid';
export const planningAreaCustomGridGeoJSONRelativePath =
'project-grid/custom-grid.geojson';

export class PlanningAreaGridCustomTransform extends Transform {
firstChunk = true;

constructor() {
super({
objectMode: true,
});
}

_transform(
chunk: { ewkb: Buffer; puid: string },
encoding: BufferEncoding,
callback: TransformCallback,
): void {
const record = {
geom: chunk.ewkb.toJSON().data,
puid: parseInt(chunk.puid),
};
const content = `${record.puid},${JSON.stringify(record.geom)}`;
const data = (this.firstChunk ? '' : '\n') + content;

callback(null, data);

if (this.firstChunk) {
this.firstChunk = false;
}
}
}

export class PlanningAreaGridCustomGeoJsonTransform extends Transform {
firstChunk = true;

constructor(bbox: number[]) {
super({
objectMode: true,
});
this.push(
`{ "type": "MultiPolygon", "bbox": [${bbox}], "coordinates": [\n`,
);
}

_transform(
chunk: { geojson: string },
encoding: BufferEncoding,
callback: TransformCallback,
): void {
const polygon: Polygon = JSON.parse(chunk.geojson);
const data =
(this.firstChunk ? '' : ',\n') + JSON.stringify(polygon.coordinates);

callback(null, data);

if (this.firstChunk) {
this.firstChunk = false;
}
}

_flush(callback: TransformCallback): void {
this.push(']}');
callback();
}
}

0 comments on commit dd75809

Please sign in to comment.