diff --git a/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts b/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts index 96eaf426f0..daf9e6ba75 100644 --- a/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts +++ b/api/apps/api/src/modules/scenarios-features/scenario-features-gap-data.service.ts @@ -17,6 +17,7 @@ import { ScenarioAccessControl } from '@marxan-api/modules/access-control/scenar import { assertDefined } from '@marxan/utils'; import { forbiddenError } from '@marxan-api/modules/access-control'; import { Either, left, right } from 'fp-ts/lib/Either'; +import { plainToClass } from 'class-transformer'; @Injectable() export class ScenarioFeaturesGapDataService extends AppBaseService< @@ -59,7 +60,19 @@ export class ScenarioFeaturesGapDataService extends AppBaseService< ) { return left(forbiddenError); } - return right(await super.findAllPaginated(fetchSpecification, info)); + /** + * @debt Explicitly applying transforms (via `plainToClass()`) here: it + * would be best to do this at AppBaseService level, but that would + * currently open a rabbit hole due to the use of generics. + */ + const { data, metadata } = await this.findAllPaginated( + fetchSpecification, + info, + ); + return right({ + data: plainToClass(ScenarioFeaturesGapData, data), + metadata, + }); } async setFilters( diff --git a/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts b/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts index 547bf69279..b945f35602 100644 --- a/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts +++ b/api/apps/api/src/modules/scenarios-features/scenario-features-output-gap-data.service.ts @@ -18,6 +18,7 @@ import { forbiddenError } from '@marxan-api/modules/access-control'; import { assertDefined } from '@marxan/utils'; import { Either, left, right } from 'fp-ts/lib/Either'; import { ScenarioAccessControl } from '@marxan-api/modules/access-control/scenarios-acl/scenario-access-control'; +import { plainToClass } from 'class-transformer'; const scenarioFeaturesOutputGapDataFilterKeyNames = ['runId'] as const; type ScenarioFeaturesOutputGapDataFilterKeys = keyof Pick< @@ -73,7 +74,19 @@ export class ScenarioFeaturesOutputGapDataService extends AppBaseService< ) { return left(forbiddenError); } - return right(await super.findAllPaginated(fetchSpecification, info)); + /** + * @debt Explicitly applying transforms (via `plainToClass()`) here: it + * would be best to do this at AppBaseService level, but that would + * currently open a rabbit hole due to the use of generics. + */ + const { data, metadata } = await this.findAllPaginated( + fetchSpecification, + info, + ); + return right({ + data: plainToClass(ScenarioFeaturesOutputGapData, data), + metadata, + }); } async setFilters( diff --git a/api/apps/api/src/modules/scenarios/scenarios.controller.ts b/api/apps/api/src/modules/scenarios/scenarios.controller.ts index d237ae6441..45fdb61277 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.controller.ts @@ -86,7 +86,6 @@ import { GeometryKind, } from '@marxan-api/decorators/file-interceptors.decorator'; import { ProtectedAreaDto } from '@marxan-api/modules/scenarios/dto/protected-area.dto'; -import { UploadShapefileDto } from '@marxan-api/modules/scenarios/dto/upload.shapefile.dto'; import { ProtectedAreasChangeDto } from '@marxan-api/modules/scenarios/dto/protected-area-change.dto'; import { StartScenarioBlmCalibrationDto } from '@marxan-api/modules/scenarios/dto/start-scenario-blm-calibration.dto'; import { BlmCalibrationRunResultDto } from './dto/scenario-blm-calibration-results.dto'; @@ -108,8 +107,6 @@ import { RequestScenarioCloneResponseDto } from './dto/scenario-clone.dto'; import { ensureShapefileHasRequiredFiles } from '@marxan-api/utils/file-uploads.utils'; import { WebshotPdfReportConfig } from '@marxan/webshot/webshot.dto'; import { ClearLockStatusParams } from '@marxan-api/modules/scenarios/dto/clear-lock-status-param.dto'; -import { CostRangeDto } from '@marxan-api/modules/scenarios/dto/cost-range.dto'; -import { plainToClass } from 'class-transformer'; import { ProjectsService } from '@marxan-api/modules/projects/projects.service'; import { CostSurfaceService } from '@marxan-api/modules/cost-surface/cost-surface.service'; diff --git a/api/apps/geoprocessing/src/migrations/geoprocessing/1710497811000-IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews.ts b/api/apps/geoprocessing/src/migrations/geoprocessing/1710497811000-IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews.ts new file mode 100644 index 0000000000..16a0591827 --- /dev/null +++ b/api/apps/geoprocessing/src/migrations/geoprocessing/1710497811000-IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews.ts @@ -0,0 +1,147 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class IncludeDecimalDigitsWhenRoundingTargetAndCurrentDataInGapDataViews1710497811000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` +drop view scenario_features_gap_data; + +create view scenario_features_gap_data as + with gap_data as ( + select + sfd.api_feature_id as feature_id, + scenario_id, + sum(total_area) total_area, + case when sum(current_pa) is not null + then sum(current_pa) + else 0 + end as met_area, + min(prop) as coverage_target + from scenario_features_data sfd + group by sfd.api_feature_id, feature_class_id, scenario_id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(met_area) as met_area, + case + when sum(total_area) > 0 + then round(((sum(met_area)/sum(total_area))*100)::numeric, 4) + else 0 + end as met, + sum(total_area) * min(coverage_target) as coverage_target_area, + round((min(coverage_target) * 100)::numeric, 4) as coverage_target, + sum(met_area) >= (sum(total_area) * min(coverage_target)) as on_target + from gap_data + group by feature_id, scenario_id; + `); + + await queryRunner.query(` +drop view scenario_features_output_gap_data; + +create view scenario_features_output_gap_data as + with gap_data as ( + select + amount, + occurrences, + run_id, + sfd.api_feature_id as feature_id, + sfd.scenario_id, + osfd.total_area, + sfd.prop as coverage_target, + osfd.target as target_met + from output_scenarios_features_data osfd + inner join scenario_features_data sfd on osfd.scenario_features_id=sfd.id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(amount) as met_area, + case + when sum(total_area) <> 0 and sum(total_area) is not null then + round(((sum(amount)/sum(total_area))*100)::numeric, 4) + else + 0 + end as met, + sum(occurrences) as met_occurrences, + sum(total_area) * min(coverage_target) as coverage_target_area, + round((min(coverage_target) * 100)::numeric, 4) as coverage_target, + bool_and(target_met) as on_target, + run_id + from gap_data + group by run_id, feature_id, scenario_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` +drop view scenario_features_gap_data; + +create view scenario_features_gap_data as + with gap_data as ( + select + sfd.api_feature_id as feature_id, + scenario_id, + sum(total_area) total_area, + case when sum(current_pa) is not null + then sum(current_pa) + else 0 + end as met_area, + min(prop) as coverage_target + from scenario_features_data sfd + group by sfd.api_feature_id, feature_class_id, scenario_id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(met_area) as met_area, + case + when sum(total_area) > 0 + then round((sum(met_area)/sum(total_area))*100) + else 0 + end as met, + sum(total_area) * min(coverage_target) as coverage_target_area, + round(min(coverage_target) * 100) as coverage_target, + sum(met_area) >= (sum(total_area) * min(coverage_target)) as on_target + from gap_data + group by feature_id, scenario_id; + `); + + await queryRunner.query(` + drop view scenario_features_output_gap_data; + + create view scenario_features_output_gap_data as + with gap_data as ( + select + amount, + occurrences, + run_id, + sfd.api_feature_id as feature_id, + sfd.scenario_id, + osfd.total_area, + sfd.prop as coverage_target, + osfd.target as target_met + from output_scenarios_features_data osfd + inner join scenario_features_data sfd on osfd.scenario_features_id=sfd.id) + select + scenario_id, + feature_id, + sum(total_area) as total_area, + sum(amount) as met_area, + case + when sum(total_area) <> 0 and sum(total_area) is not null then + round((sum(amount)/sum(total_area))*100) + else + 0 + end as met, + sum(occurrences) as met_occurrences, + sum(total_area) * min(coverage_target) as coverage_target_area, + round(min(coverage_target) * 100) as coverage_target, + bool_and(target_met) as on_target, + run_id + from gap_data + group by run_id, feature_id, scenario_id; + `); + } +} diff --git a/api/libs/features/src/scenario-features-gap-data.geo.entity.ts b/api/libs/features/src/scenario-features-gap-data.geo.entity.ts index a73a7f2d51..c9d4450a4b 100644 --- a/api/libs/features/src/scenario-features-gap-data.geo.entity.ts +++ b/api/libs/features/src/scenario-features-gap-data.geo.entity.ts @@ -1,4 +1,6 @@ +import { numericStringToFloat } from '@marxan/utils/numeric-string-to-float.utils'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { Column, ViewEntity } from 'typeorm'; @ViewEntity('scenario_features_gap_data') @@ -20,7 +22,10 @@ export class ScenarioFeaturesGapData { metArea!: number; @ApiProperty() - @Column({ name: 'met' }) + // explicitly set type, otherwise TypeORM (v10, at least) will cast to integer + // TypeORM will still represent the value as string though (https://github.com/typeorm/typeorm/issues/873#issuecomment-328912050) + @Column({ name: 'met', type: 'double precision' }) + @Transform(numericStringToFloat) met!: number; @ApiProperty() @@ -28,7 +33,10 @@ export class ScenarioFeaturesGapData { coverageTargetArea!: number; @ApiProperty() - @Column({ name: 'coverage_target' }) + // explicitly set type, otherwise TypeORM (v10, at least) will cast to integer + // TypeORM will still represent the value as string though (https://github.com/typeorm/typeorm/issues/873#issuecomment-328912050) + @Column({ name: 'coverage_target', type: 'double precision' }) + @Transform(numericStringToFloat) coverageTarget!: number; @ApiProperty() diff --git a/api/libs/utils/src/numeric-string-to-float.utils.spec.ts b/api/libs/utils/src/numeric-string-to-float.utils.spec.ts new file mode 100644 index 0000000000..1bddc129b3 --- /dev/null +++ b/api/libs/utils/src/numeric-string-to-float.utils.spec.ts @@ -0,0 +1,21 @@ +import { numericStringToFloat } from './numeric-string-to-float.utils'; + +describe('parseOptionalFloat', () => { + it('should return undefined if the value is undefined', () => { + expect(numericStringToFloat(undefined)).toBeUndefined(); + }); + + it('should throw an exception if the value is not numeric', () => { + expect(() => numericStringToFloat('foo')).toThrow('Invalid number: foo'); + }); + + it('should return a numeric representation of the value if the value is numeric', () => { + expect(numericStringToFloat('123.456')).toBe(123.456); + }); + + it('should silently round a float to the maximum precision supported by javascript', () => { + expect(numericStringToFloat('123.456789012345678901234567890')).toBe( + 123.45678901234568, + ); + }); +}); diff --git a/api/libs/utils/src/numeric-string-to-float.utils.ts b/api/libs/utils/src/numeric-string-to-float.utils.ts new file mode 100644 index 0000000000..af3ac3fa38 --- /dev/null +++ b/api/libs/utils/src/numeric-string-to-float.utils.ts @@ -0,0 +1,24 @@ +import { isNil } from 'lodash'; + +/** + * Kind of like parseFloat(), but passing through undefined values, and handling + * values that don't cast to a number. + * + * @debt It silently rounds to the maximum precision supported by Javascript any + * input values that are numeric but beyond what can be represented in a + * Javascript number (not BigInt). Infinity and -Infinity are also passed + * through as corresponding Javascript Infinity numeric values. + */ +export function numericStringToFloat( + value: string | undefined, +): number | undefined { + // +(null) === 0, so we only cast if input is neither undefined nor null. + if (!isNil(value)) { + const floatValue = +value; + if (!isNaN(floatValue)) { + return floatValue; + } + throw new Error(`Invalid number: ${value}`); + } + return; +} diff --git a/app/components/gap-analysis/item/component.tsx b/app/components/gap-analysis/item/component.tsx index c53f21b6b9..0416b391c9 100644 --- a/app/components/gap-analysis/item/component.tsx +++ b/app/components/gap-analysis/item/component.tsx @@ -41,7 +41,7 @@ export const Item: React.FC<ItemProps> = ({ }: ItemProps) => { const chartRef = useRef<HTMLDivElement>(null); const [chartEl, setChartEl] = useState(null); - const percentFormatter = useNumberFormatter({ style: 'percent' }); + const percentFormatter = useNumberFormatter({ style: 'percent', maximumFractionDigits: 4 }); const metStyles = useMemo(() => { if (chartEl) {