From ed60b240246d78ad5392fe5ec9a80cc9bb183bc9 Mon Sep 17 00:00:00 2001 From: DipShre Date: Thu, 2 Jul 2026 22:08:22 +0200 Subject: [PATCH 1/4] polygons i kart + egen route close #63 --- Artskart3.Api/Controllers/SearchController.cs | 33 +++ .../Application/DTOs/LocationPolygonDto.cs | 12 ++ .../Services/Implementations/SearchService.cs | 5 + .../Services/Interfaces/ISearchService.cs | 1 + .../RepositoryInterfaces/ISearchRepository.cs | 1 + .../Repositories/SearchRepository.cs | 66 ++++++ Artskart3.Tests.Unit/SearchRepositoryTests.cs | 202 ++++++++++++++++++ .../app/core/services/areas/areas.service.ts | 48 +++++ .../components/map.component/map.component.ts | 35 ++- .../shared/models/area/area-marker.model.ts | 7 + 10 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 Artskart3.Core/Application/DTOs/LocationPolygonDto.cs diff --git a/Artskart3.Api/Controllers/SearchController.cs b/Artskart3.Api/Controllers/SearchController.cs index b1465e65..c43cd28f 100644 --- a/Artskart3.Api/Controllers/SearchController.cs +++ b/Artskart3.Api/Controllers/SearchController.cs @@ -382,4 +382,37 @@ private bool IsValidCoordinatePrecisionRange(int from, int to) /// private object CreateRangeErrorMessage(int min, int max) => new { error = $"Value must be between {min} and {max}." }; + + /// + /// Retrieves polygon geometries from the Location table for observations matching the filter. + /// Only Polygon and MultiPolygon geometry types are returned. + /// Rectangular/square polygons (grid-cell precision squares) are excluded automatically. + /// + [HttpPost("LocationPolygons")] + [Produces("application/json")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> GetLocationPolygons( + [FromBody] LocationSearchFilterDto? filter = null, + CancellationToken cancellationToken = default) + { + filter ??= new LocationSearchFilterDto(); + + if (!ValidateLocationSearchFilter(filter, out var validationError)) + { + return validationError!; + } + + try + { + var polygons = await _searchService.GetLocationPolygonsAsync(filter, cancellationToken); + _logger.LogInformation("Retrieved {Count} location polygons", polygons.Count()); + return Ok(polygons); + } + catch (Exception ex) + { + _logger.LogError(ex, "Feil ved henting av lokasjonspolygoner"); + throw; + } + } } diff --git a/Artskart3.Core/Application/DTOs/LocationPolygonDto.cs b/Artskart3.Core/Application/DTOs/LocationPolygonDto.cs new file mode 100644 index 00000000..b9cc6bad --- /dev/null +++ b/Artskart3.Core/Application/DTOs/LocationPolygonDto.cs @@ -0,0 +1,12 @@ +namespace Artskart3.Core.Application.DTOs; + +/// +/// Represents a location's polygon geometry. +/// +public class LocationPolygonDto +{ + public int LocationId { get; set; } + public string? Locality { get; set; } + public string WktPolygon { get; set; } = null!; + public int ObservationCount { get; set; } +} diff --git a/Artskart3.Core/Application/Services/Implementations/SearchService.cs b/Artskart3.Core/Application/Services/Implementations/SearchService.cs index 9f690706..a5fde007 100644 --- a/Artskart3.Core/Application/Services/Implementations/SearchService.cs +++ b/Artskart3.Core/Application/Services/Implementations/SearchService.cs @@ -68,4 +68,9 @@ public async Task> GetAreaMarkersAsync(int zoomLevel, return await _searchRepository.GetAreaMarkersAsync(zoomLevel, filter, cancellationToken); } + + public async Task> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default) + { + return await _searchRepository.GetLocationPolygonsAsync(filter, cancellationToken); + } } diff --git a/Artskart3.Core/Application/Services/Interfaces/ISearchService.cs b/Artskart3.Core/Application/Services/Interfaces/ISearchService.cs index c0f69c1f..4cbbb922 100644 --- a/Artskart3.Core/Application/Services/Interfaces/ISearchService.cs +++ b/Artskart3.Core/Application/Services/Interfaces/ISearchService.cs @@ -9,4 +9,5 @@ public interface ISearchService Task> GetObservationsAsync(ObservationSearchFilterDto filter, CancellationToken cancellationToken = default); Task> GetAreaMarkersAsync(int zoomLevel, LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default); + Task> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default); } diff --git a/Artskart3.Core/Domain/RepositoryInterfaces/ISearchRepository.cs b/Artskart3.Core/Domain/RepositoryInterfaces/ISearchRepository.cs index 79ced78b..ab1677ac 100644 --- a/Artskart3.Core/Domain/RepositoryInterfaces/ISearchRepository.cs +++ b/Artskart3.Core/Domain/RepositoryInterfaces/ISearchRepository.cs @@ -9,4 +9,5 @@ public interface ISearchRepository IAsyncEnumerable GetLocationsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default); Task> GetObservationsAsync(ObservationSearchFilterDto filter, CancellationToken cancellationToken = default); Task> GetAreaMarkersAsync(int zoomLevel, LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default); + Task> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default); } diff --git a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs index 204f0a43..102895f6 100644 --- a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs +++ b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs @@ -640,4 +640,70 @@ private List FilterAreasBySelection(List areas, LocationSearchFilter ).ToList(); } + /// + /// Henter polygon-geometrier fra Location-tabellen for observasjoner som matcher filteret. + /// + public async Task> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default) + { + try + { + filter ??= new LocationSearchFilterDto(); + + var query = BuildLocationsQuery(filter); + var aggregated = await AggregateLocationObservations(query, filter.MaxResults, cancellationToken); + + if (aggregated.Count == 0) return []; + + var locationIds = aggregated.Select(x => x.LocationId).ToList(); + + var locations = await _context.Set() + .AsNoTracking() + .Where(l => locationIds.Contains(l.Id) && l.Geometry != null && l.Geometry.NumPoints > 5) + .ToListAsync(cancellationToken); + + if (locations.Count == 0) return []; + + var countLookup = aggregated.ToDictionary(x => x.LocationId, x => x.ObservationCount); + + var result = new List(); + + foreach (var location in locations) + { + var geo = location.Geometry; + if (geo == null) continue; + + if (geo.GeometryType != "Polygon" && geo.GeometryType != "MultiPolygon") continue; + + if (IsAxisAlignedRectangle(geo)) continue; + + result.Add(new LocationPolygonDto + { + LocationId = location.Id, + Locality = location.Locality, + WktPolygon = geo.AsText(), + ObservationCount = countLookup.GetValueOrDefault(location.Id) + }); + } + + _logger.LogInformation("Location polygon search completed. Returned {Count} polygons", result.Count); + return result; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Feil ved henting av lokasjonspolygoner"); + throw new ApplicationException("An error occurred while retrieving location polygons. Please try again later.", ex); + } + } + + /// + /// Returns true when the geometry is an axis-aligned rectangle. + /// Any non-rectangular polygon will always have area strictly less than its bounding box. + /// + private static bool IsAxisAlignedRectangle(NetTopologySuite.Geometries.Geometry geo) + { + var envelope = geo.EnvelopeInternal; + if (envelope.Area <= 0) return false; + return Math.Abs(geo.Area - envelope.Area) / envelope.Area < 1e-10; + } + } diff --git a/Artskart3.Tests.Unit/SearchRepositoryTests.cs b/Artskart3.Tests.Unit/SearchRepositoryTests.cs index 3ed1aa50..9a21b2db 100644 --- a/Artskart3.Tests.Unit/SearchRepositoryTests.cs +++ b/Artskart3.Tests.Unit/SearchRepositoryTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; +using NtsGeom = NetTopologySuite.Geometries; namespace Artskart3.Tests.Unit; @@ -409,6 +410,152 @@ public async Task GetLocationsAsync_WhenLocationRowMissing_OmitsFromResults() result[0].Id.Should().Be(1); } + // ---- GetLocationPolygonsAsync ---- + + [Fact] + public async Task GetLocationPolygonsAsync_WithNoObservations_ReturnsEmpty() + { + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetLocationPolygonsAsync_WithNullGeometry_ExcludesLocation() + { + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, CreateLocation(1, "Oslo")); // Geometry = null + SeedObservations(context, CreateObservation(1, 1)); + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetLocationPolygonsAsync_WithRectangularPolygon5Points_ExcludesLocation() + { + // 5-point rectangle: NumPoints = 5, filtered out by the NumPoints > 5 query predicate + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateRectangle5Pt())); + SeedObservations(context, CreateObservation(1, 1)); + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetLocationPolygonsAsync_WithRectangularPolygon8Points_ExcludesLocation() + { + // 8-point rectangle (midpoints on each edge): passes NumPoints > 5 but caught by IsAxisAlignedRectangle + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateRectangle8Pt())); + SeedObservations(context, CreateObservation(1, 1)); + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetLocationPolygonsAsync_WithIrregularPolygon_IncludesLocation() + { + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon())); + SeedObservations(context, CreateObservation(1, 1)); + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().ContainSingle(); + result[0].LocationId.Should().Be(1); + result[0].Locality.Should().Be("Oslo"); + } + + [Fact] + public async Task GetLocationPolygonsAsync_WithMixedGeometries_ReturnsOnlyNonRectangular() + { + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, + CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon()), + CreateLocationWithGeometry(2, "Bergen", CreateRectangle5Pt()), + CreateLocationWithGeometry(3, "Trondheim", CreateRectangle8Pt()), + CreateLocation(4, "Stavanger")); // null geometry + + SeedObservations(context, + CreateObservation(1, 1), + CreateObservation(2, 2), + CreateObservation(3, 3), + CreateObservation(4, 4)); + + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().ContainSingle(); + result[0].LocationId.Should().Be(1); + } + + [Fact] + public async Task GetLocationPolygonsAsync_ReturnsCorrectObservationCount() + { + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon())); + SeedObservations(context, + CreateObservation(1, 1), + CreateObservation(2, 1), + CreateObservation(3, 1)); + + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync()).ToList(); + + result.Should().ContainSingle(); + result[0].ObservationCount.Should().Be(3); + } + + [Fact] + public async Task GetLocationPolygonsAsync_WithTaxonGroupFilter_ExcludesNonMatchingLocations() + { + await using var context = CreateInMemoryContext(); + var sut = CreateRepository(context); + + SeedLocations(context, + CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon()), + CreateLocationWithGeometry(2, "Bergen", CreateIrregularPolygon())); + + SeedObservations(context, + CreateObservation(1, 1, taxonGroupId: 1), + CreateObservation(2, 2, taxonGroupId: 2)); + + await context.SaveChangesAsync(); + + var result = (await sut.GetLocationPolygonsAsync( + new LocationSearchFilterDto { TaxonGroupIds = [1] })).ToList(); + + result.Should().ContainSingle(); + result[0].LocationId.Should().Be(1); + } + private static ArtskartDbContext CreateInMemoryContext() { var options = new DbContextOptionsBuilder() @@ -494,6 +641,61 @@ private static Area CreateArea(int id, string name, int areaTypeId, int? observa Centroid = null }; + private static Artskart3.Core.Domain.Entities.Location CreateLocationWithGeometry(int id, string locality, NtsGeom.Geometry geometry) => + new() + { + Id = id, + Locality = locality, + Latitude = 59.91 + id, + Longitude = 10.75 + id, + East = 1000 + id, + North = 2000 + id, + CoordinatePrecision = 25, + NodeId = 1, + TimeStamp = DateTime.UtcNow, + Geometry = geometry + }; + + // Axis-aligned rectangle — 5 ring points, NumPoints = 5 (excluded by NumPoints > 5 query filter) + private static NtsGeom.Geometry CreateRectangle5Pt() => + new NtsGeom.GeometryFactory().CreatePolygon( + [ + new NtsGeom.Coordinate(0, 0), + new NtsGeom.Coordinate(1000, 0), + new NtsGeom.Coordinate(1000, 1000), + new NtsGeom.Coordinate(0, 1000), + new NtsGeom.Coordinate(0, 0), + ]); + + // Axis-aligned rectangle with midpoints on each side — 9 ring points, NumPoints = 9 + // Passes the NumPoints > 5 filter; excluded by IsAxisAlignedRectangle (area == envelope area) + private static NtsGeom.Geometry CreateRectangle8Pt() => + new NtsGeom.GeometryFactory().CreatePolygon( + [ + new NtsGeom.Coordinate(0, 0), + new NtsGeom.Coordinate(500, 0), + new NtsGeom.Coordinate(1000, 0), + new NtsGeom.Coordinate(1000, 500), + new NtsGeom.Coordinate(1000, 1000), + new NtsGeom.Coordinate(500, 1000), + new NtsGeom.Coordinate(0, 1000), + new NtsGeom.Coordinate(0, 500), + new NtsGeom.Coordinate(0, 0), + ]); + + // Irregular hexagon — 7 ring points, area < envelope area; passes all filters + private static NtsGeom.Geometry CreateIrregularPolygon() => + new NtsGeom.GeometryFactory().CreatePolygon( + [ + new NtsGeom.Coordinate(0, 0), + new NtsGeom.Coordinate(1000, 0), + new NtsGeom.Coordinate(1500, 500), + new NtsGeom.Coordinate(1000, 1000), + new NtsGeom.Coordinate(0, 1000), + new NtsGeom.Coordinate(-500, 500), + new NtsGeom.Coordinate(0, 0), + ]); + private static async Task> ToListAsync(IAsyncEnumerable source) { var list = new List(); diff --git a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts index ca3bc02d..8271cedb 100644 --- a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts +++ b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts @@ -14,6 +14,7 @@ import { map } from 'rxjs/operators'; import { AreaMarkerDto, AreaMarkerFeature, + LocationPolygonDto, } from '@shared/models/area/area-marker.model'; import { AbbreviateNumberHelper } from '@shared/helpers/number/abbreviate-number.helper'; import { ZoomConfig } from '@shared/helpers/zoom/zoom-config'; @@ -297,6 +298,7 @@ export class AreasService { private readonly areasBaseEndpoint = '/api/Search/AreaMarkers'; private readonly locationsEndpoint = '/api/Search/Locations'; + private readonly locationPolygonsEndpoint = '/api/Search/LocationPolygons'; /** * Henter områdemarkører fra API for gitt zoomnivå. @@ -341,6 +343,52 @@ export class AreasService { ); } + /** + * Fetches location polygon geometries and returns as a GeoJSON FeatureCollection string. + */ + getLocationPolygons(filter?: LocationSearchFilter): Observable { + const body = this.buildFilterBody(filter); + + return this.apiClientService.postJson(this.locationPolygonsEndpoint, body).pipe( + map(polygons => { + if (!Array.isArray(polygons)) { + return JSON.stringify({ type: 'FeatureCollection', features: [] }); + } + + const features = polygons + .map(p => this.createPolygonFeature(p)) + .filter((f): f is AreaMarkerFeature => f !== null); + + this.loggerService.info(`Retrieved ${features.length} location polygon features`, AreasService.SERVICE_NAME); + return JSON.stringify({ type: 'FeatureCollection', features }); + }) + ); + } + + private createPolygonFeature(dto: LocationPolygonDto): AreaMarkerFeature | null { + const parsed = parseWkt(dto.wktPolygon); + if (!parsed) return null; + + const count = dto.observationCount ?? 0; + return { + type: 'Feature', + id: dto.locationId, + geometry: { type: parsed.type, coordinates: parsed.coordinates }, + properties: { + id: dto.locationId, + name: dto.locality ?? `Location ${dto.locationId}`, + observationCount: count, + observationCountDisplay: count > 0 ? AbbreviateNumberHelper.format(count) : '', + isPolygon: true, + 'nbic:style': { + fillColor: 'rgba(0, 90, 113, 0.25)', + strokeColor: '#005A71', + strokeWidth: 1.5, + }, + }, + }; + } + private buildFilterBody(filter?: LocationSearchFilter, extent?: [number, number, number, number]): Record { const body: Record = {}; diff --git a/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts b/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts index c9f3d5f6..0bdf1028 100644 --- a/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts @@ -17,7 +17,7 @@ import { effect, } from '@angular/core'; import { LoggingService } from '@shared/logging.service'; -import { Subject, EMPTY } from 'rxjs'; +import { Subject, EMPTY, merge } from 'rxjs'; import { catchError, debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators'; import { AreasService, LocationSearchFilter } from '@core/services/areas/areas.service'; import { AreaMarkerDto } from '@shared/models/area/area-marker.model'; @@ -50,6 +50,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { private readonly COUNTIES_LAYER_ID = 'area-markers-counties'; private readonly MUNICIPALITIES_LAYER_ID = 'area-markers-municipalities'; private readonly LOCATIONS_LAYER_ID = 'area-markers-locations'; + private readonly LOCATION_POLYGONS_LAYER_ID = 'location-polygons'; public map!: NbicMapComponent; private zoomControl?: ArtskartZoomControl; @@ -272,6 +273,15 @@ export class MapComponent implements AfterViewInit, OnDestroy { }, }, }); + + this.map.addLayer({ + id: this.LOCATION_POLYGONS_LAYER_ID, + kind: 'vector', + source: { type: 'memory' }, + pickable: true, + zIndex: 90, + minZoom: ZoomConfig.ZOOM_MUNICIPALITIES_THRESHOLD, + }); } private onCameraChanged(zoom: number): void { @@ -315,14 +325,21 @@ export class MapComponent implements AfterViewInit, OnDestroy { } const filter = this.locationFilter(); - return this.areasService.getLocationsAsGeoJsonString(extent, filter).pipe( - tap(geojson => { - this.applyGeoJsonToLayer(apiZoomLevel, geojson); - }), - catchError((err: unknown) => { - this.logger.error(`Failed to load location points:`, 'MapComponent', err); - return EMPTY; - }) + return merge( + this.areasService.getLocationsAsGeoJsonString(extent, filter).pipe( + tap(locations => this.applyGeoJsonToLayer(apiZoomLevel, locations)), + catchError((err: unknown) => { + this.logger.error('Failed to load location points:', 'MapComponent', err); + return EMPTY; + }) + ), + this.areasService.getLocationPolygons(filter).pipe( + tap(polygons => this.map.updateGeoJSONLayer(this.LOCATION_POLYGONS_LAYER_ID, polygons, { mode: 'replace' })), + catchError((err: unknown) => { + this.logger.error('Failed to load location polygons:', 'MapComponent', err); + return EMPTY; + }) + ) ); }), takeUntil(this.destroy$), diff --git a/Artskart3.WebApp/src/app/shared/models/area/area-marker.model.ts b/Artskart3.WebApp/src/app/shared/models/area/area-marker.model.ts index 7dab1139..a27c878b 100644 --- a/Artskart3.WebApp/src/app/shared/models/area/area-marker.model.ts +++ b/Artskart3.WebApp/src/app/shared/models/area/area-marker.model.ts @@ -69,3 +69,10 @@ export function getAreaTypeName(areaTypeId: number): string { export function getAreaTypeColor(areaTypeId: number): string { return AREA_TYPE_CONFIG[areaTypeId]?.color ?? '#005A71'; } + +export interface LocationPolygonDto { + locationId: number; + locality?: string; + wktPolygon: string; + observationCount: number; +} From 8d461e38358eda35905c6375741a1162e441d587 Mon Sep 17 00:00:00 2001 From: DipShre Date: Thu, 2 Jul 2026 22:48:23 +0200 Subject: [PATCH 2/4] update EndpointCoverageTests close #63 Artskart3.Tests.Integration.Tests.EndpointCoverageTests --- .../Tests/SearchEndpointTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Artskart3.Tests.Integration/Tests/SearchEndpointTests.cs b/Artskart3.Tests.Integration/Tests/SearchEndpointTests.cs index 0b15d0fc..56546320 100644 --- a/Artskart3.Tests.Integration/Tests/SearchEndpointTests.cs +++ b/Artskart3.Tests.Integration/Tests/SearchEndpointTests.cs @@ -210,4 +210,30 @@ public async Task GetAreaMarkers_WithZoomLevel2_Returns200WithJsonArray() var doc = JsonDocument.Parse(json); doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); } + + // ----------------------------------------------------------------------- + // POST /api/Search/LocationPolygons + // ----------------------------------------------------------------------- + + [Fact] + public async Task GetLocationPolygons_WithNoFilter_Returns200WithJsonArray() + { + var response = await _client.PostAsJsonAsync("/api/Search/LocationPolygons", new { }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + } + + [Fact] + public async Task GetLocationPolygons_WithInvertedPrecisionRange_Returns400() + { + var response = await _client.PostAsJsonAsync("/api/Search/LocationPolygons", + new { coordinatePrecision = new { from = 1000, to = 100 } }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } } From e536eaaf4fcdaace4372d10d0ce6ead763765846 Mon Sep 17 00:00:00 2001 From: DipShre Date: Mon, 6 Jul 2026 10:27:32 +0200 Subject: [PATCH 3/4] implementert envelope + refacktor cloe #63 --- .../Repositories/SearchRepository.cs | 39 +++- Artskart3.Tests.Unit/SearchRepositoryTests.cs | 202 ------------------ .../app/core/services/areas/areas.service.ts | 4 +- .../components/map.component/map.component.ts | 2 +- 4 files changed, 33 insertions(+), 214 deletions(-) diff --git a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs index 102895f6..cab0370b 100644 --- a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs +++ b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs @@ -642,6 +642,7 @@ private List FilterAreasBySelection(List areas, LocationSearchFilter /// /// Henter polygon-geometrier fra Location-tabellen for observasjoner som matcher filteret. + /// Rektangulære polygoner (nøyaktig 5 punkter i ytre ring = rutenett-firkanter) filtreres bort. /// public async Task> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default) { @@ -658,7 +659,7 @@ public async Task> GetLocationPolygonsAsync(Loca var locations = await _context.Set() .AsNoTracking() - .Where(l => locationIds.Contains(l.Id) && l.Geometry != null && l.Geometry.NumPoints > 5) + .Where(l => locationIds.Contains(l.Id) && l.Geometry != null) .ToListAsync(cancellationToken); if (locations.Count == 0) return []; @@ -672,15 +673,26 @@ public async Task> GetLocationPolygonsAsync(Loca var geo = location.Geometry; if (geo == null) continue; + // Only include Polygon and MultiPolygon — skip Point, LineString, etc. if (geo.GeometryType != "Polygon" && geo.GeometryType != "MultiPolygon") continue; - if (IsAxisAlignedRectangle(geo)) continue; + var wkt = geo.AsText(); + if (IsRectangularPolygon(wkt)) continue; + + // Skip polygons whose bounding box doesn't intersect the visible map extent + if (filter.Envelope != null) + { + var env = geo.EnvelopeInternal; + if (env.MaxX < filter.Envelope.MinX || env.MinX > filter.Envelope.MaxX || + env.MaxY < filter.Envelope.MinY || env.MinY > filter.Envelope.MaxY) + continue; + } result.Add(new LocationPolygonDto { LocationId = location.Id, Locality = location.Locality, - WktPolygon = geo.AsText(), + WktPolygon = wkt, ObservationCount = countLookup.GetValueOrDefault(location.Id) }); } @@ -696,14 +708,23 @@ public async Task> GetLocationPolygonsAsync(Loca } /// - /// Returns true when the geometry is an axis-aligned rectangle. - /// Any non-rectangular polygon will always have area strictly less than its bounding box. + /// Returns true when the WKT string represents a rectangular polygon (exactly 5 coordinate pairs in the exterior ring). /// - private static bool IsAxisAlignedRectangle(NetTopologySuite.Geometries.Geometry geo) + private static bool IsRectangularPolygon(string? wkt) { - var envelope = geo.EnvelopeInternal; - if (envelope.Area <= 0) return false; - return Math.Abs(geo.Area - envelope.Area) / envelope.Area < 1e-10; + if (string.IsNullOrEmpty(wkt)) return false; + var ringStart = wkt.IndexOf('(', wkt.IndexOf('(') + 1); + var ringEnd = wkt.IndexOf(')', ringStart); + if (ringStart < 0 || ringEnd < 0) return false; + + var ring = wkt.AsSpan(ringStart + 1, ringEnd - ringStart - 1); + var commaCount = 0; + foreach (var ch in ring) + { + if (ch == ',') commaCount++; + } + + return commaCount == 4; } } diff --git a/Artskart3.Tests.Unit/SearchRepositoryTests.cs b/Artskart3.Tests.Unit/SearchRepositoryTests.cs index 9a21b2db..3ed1aa50 100644 --- a/Artskart3.Tests.Unit/SearchRepositoryTests.cs +++ b/Artskart3.Tests.Unit/SearchRepositoryTests.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; -using NtsGeom = NetTopologySuite.Geometries; namespace Artskart3.Tests.Unit; @@ -410,152 +409,6 @@ public async Task GetLocationsAsync_WhenLocationRowMissing_OmitsFromResults() result[0].Id.Should().Be(1); } - // ---- GetLocationPolygonsAsync ---- - - [Fact] - public async Task GetLocationPolygonsAsync_WithNoObservations_ReturnsEmpty() - { - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().BeEmpty(); - } - - [Fact] - public async Task GetLocationPolygonsAsync_WithNullGeometry_ExcludesLocation() - { - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, CreateLocation(1, "Oslo")); // Geometry = null - SeedObservations(context, CreateObservation(1, 1)); - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().BeEmpty(); - } - - [Fact] - public async Task GetLocationPolygonsAsync_WithRectangularPolygon5Points_ExcludesLocation() - { - // 5-point rectangle: NumPoints = 5, filtered out by the NumPoints > 5 query predicate - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateRectangle5Pt())); - SeedObservations(context, CreateObservation(1, 1)); - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().BeEmpty(); - } - - [Fact] - public async Task GetLocationPolygonsAsync_WithRectangularPolygon8Points_ExcludesLocation() - { - // 8-point rectangle (midpoints on each edge): passes NumPoints > 5 but caught by IsAxisAlignedRectangle - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateRectangle8Pt())); - SeedObservations(context, CreateObservation(1, 1)); - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().BeEmpty(); - } - - [Fact] - public async Task GetLocationPolygonsAsync_WithIrregularPolygon_IncludesLocation() - { - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon())); - SeedObservations(context, CreateObservation(1, 1)); - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().ContainSingle(); - result[0].LocationId.Should().Be(1); - result[0].Locality.Should().Be("Oslo"); - } - - [Fact] - public async Task GetLocationPolygonsAsync_WithMixedGeometries_ReturnsOnlyNonRectangular() - { - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, - CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon()), - CreateLocationWithGeometry(2, "Bergen", CreateRectangle5Pt()), - CreateLocationWithGeometry(3, "Trondheim", CreateRectangle8Pt()), - CreateLocation(4, "Stavanger")); // null geometry - - SeedObservations(context, - CreateObservation(1, 1), - CreateObservation(2, 2), - CreateObservation(3, 3), - CreateObservation(4, 4)); - - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().ContainSingle(); - result[0].LocationId.Should().Be(1); - } - - [Fact] - public async Task GetLocationPolygonsAsync_ReturnsCorrectObservationCount() - { - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon())); - SeedObservations(context, - CreateObservation(1, 1), - CreateObservation(2, 1), - CreateObservation(3, 1)); - - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync()).ToList(); - - result.Should().ContainSingle(); - result[0].ObservationCount.Should().Be(3); - } - - [Fact] - public async Task GetLocationPolygonsAsync_WithTaxonGroupFilter_ExcludesNonMatchingLocations() - { - await using var context = CreateInMemoryContext(); - var sut = CreateRepository(context); - - SeedLocations(context, - CreateLocationWithGeometry(1, "Oslo", CreateIrregularPolygon()), - CreateLocationWithGeometry(2, "Bergen", CreateIrregularPolygon())); - - SeedObservations(context, - CreateObservation(1, 1, taxonGroupId: 1), - CreateObservation(2, 2, taxonGroupId: 2)); - - await context.SaveChangesAsync(); - - var result = (await sut.GetLocationPolygonsAsync( - new LocationSearchFilterDto { TaxonGroupIds = [1] })).ToList(); - - result.Should().ContainSingle(); - result[0].LocationId.Should().Be(1); - } - private static ArtskartDbContext CreateInMemoryContext() { var options = new DbContextOptionsBuilder() @@ -641,61 +494,6 @@ private static Area CreateArea(int id, string name, int areaTypeId, int? observa Centroid = null }; - private static Artskart3.Core.Domain.Entities.Location CreateLocationWithGeometry(int id, string locality, NtsGeom.Geometry geometry) => - new() - { - Id = id, - Locality = locality, - Latitude = 59.91 + id, - Longitude = 10.75 + id, - East = 1000 + id, - North = 2000 + id, - CoordinatePrecision = 25, - NodeId = 1, - TimeStamp = DateTime.UtcNow, - Geometry = geometry - }; - - // Axis-aligned rectangle — 5 ring points, NumPoints = 5 (excluded by NumPoints > 5 query filter) - private static NtsGeom.Geometry CreateRectangle5Pt() => - new NtsGeom.GeometryFactory().CreatePolygon( - [ - new NtsGeom.Coordinate(0, 0), - new NtsGeom.Coordinate(1000, 0), - new NtsGeom.Coordinate(1000, 1000), - new NtsGeom.Coordinate(0, 1000), - new NtsGeom.Coordinate(0, 0), - ]); - - // Axis-aligned rectangle with midpoints on each side — 9 ring points, NumPoints = 9 - // Passes the NumPoints > 5 filter; excluded by IsAxisAlignedRectangle (area == envelope area) - private static NtsGeom.Geometry CreateRectangle8Pt() => - new NtsGeom.GeometryFactory().CreatePolygon( - [ - new NtsGeom.Coordinate(0, 0), - new NtsGeom.Coordinate(500, 0), - new NtsGeom.Coordinate(1000, 0), - new NtsGeom.Coordinate(1000, 500), - new NtsGeom.Coordinate(1000, 1000), - new NtsGeom.Coordinate(500, 1000), - new NtsGeom.Coordinate(0, 1000), - new NtsGeom.Coordinate(0, 500), - new NtsGeom.Coordinate(0, 0), - ]); - - // Irregular hexagon — 7 ring points, area < envelope area; passes all filters - private static NtsGeom.Geometry CreateIrregularPolygon() => - new NtsGeom.GeometryFactory().CreatePolygon( - [ - new NtsGeom.Coordinate(0, 0), - new NtsGeom.Coordinate(1000, 0), - new NtsGeom.Coordinate(1500, 500), - new NtsGeom.Coordinate(1000, 1000), - new NtsGeom.Coordinate(0, 1000), - new NtsGeom.Coordinate(-500, 500), - new NtsGeom.Coordinate(0, 0), - ]); - private static async Task> ToListAsync(IAsyncEnumerable source) { var list = new List(); diff --git a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts index 8271cedb..511c3f92 100644 --- a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts +++ b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts @@ -346,8 +346,8 @@ export class AreasService { /** * Fetches location polygon geometries and returns as a GeoJSON FeatureCollection string. */ - getLocationPolygons(filter?: LocationSearchFilter): Observable { - const body = this.buildFilterBody(filter); + getLocationPolygons(extent?: [number, number, number, number], filter?: LocationSearchFilter): Observable { + const body = this.buildFilterBody(filter, extent); return this.apiClientService.postJson(this.locationPolygonsEndpoint, body).pipe( map(polygons => { diff --git a/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts b/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts index 0bdf1028..35f5f2d7 100644 --- a/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/map.component/map.component.ts @@ -333,7 +333,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { return EMPTY; }) ), - this.areasService.getLocationPolygons(filter).pipe( + this.areasService.getLocationPolygons(extent, filter).pipe( tap(polygons => this.map.updateGeoJSONLayer(this.LOCATION_POLYGONS_LAYER_ID, polygons, { mode: 'replace' })), catchError((err: unknown) => { this.logger.error('Failed to load location polygons:', 'MapComponent', err); From 2a91a2c14e12e8d094cda010422aa4f90ef50a51 Mon Sep 17 00:00:00 2001 From: DipShre Date: Mon, 6 Jul 2026 13:53:17 +0200 Subject: [PATCH 4/4] Max Polygon Results + small changes close #328 --- Artskart3.Core/Constants/SearchConstants.cs | 4 ++++ .../Persistence/Repositories/SearchRepository.cs | 15 +++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Artskart3.Core/Constants/SearchConstants.cs b/Artskart3.Core/Constants/SearchConstants.cs index 487507af..4a62a77e 100644 --- a/Artskart3.Core/Constants/SearchConstants.cs +++ b/Artskart3.Core/Constants/SearchConstants.cs @@ -12,6 +12,10 @@ public static class SearchConstants public const int MaxLocationResults = 100000; public const int MinLocationResults = 1; + // Polygon search constants (geometry is expensive to transfer and render) + public const int DefaultMaxPolygons = 2000; + public const int MaxPolygonResults = 5000; + // Observation search constants public const int DefaultMaxObservations = 20; public const int MaxObservationResults = 10000; diff --git a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs index cab0370b..d53735d2 100644 --- a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs +++ b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs @@ -651,7 +651,10 @@ public async Task> GetLocationPolygonsAsync(Loca filter ??= new LocationSearchFilterDto(); var query = BuildLocationsQuery(filter); - var aggregated = await AggregateLocationObservations(query, filter.MaxResults, cancellationToken); + var polygonMaxResults = filter.MaxResults > 0 + ? Math.Min(filter.MaxResults, SearchConstants.MaxPolygonResults) + : SearchConstants.DefaultMaxPolygons; + var aggregated = await AggregateLocationObservations(query, polygonMaxResults, cancellationToken); if (aggregated.Count == 0) return []; @@ -659,7 +662,8 @@ public async Task> GetLocationPolygonsAsync(Loca var locations = await _context.Set() .AsNoTracking() - .Where(l => locationIds.Contains(l.Id) && l.Geometry != null) + .Where(l => locationIds.Contains(l.Id) && l.Geometry != null && (l.Geometry.GeometryType == "Polygon" || l.Geometry.GeometryType == "MultiPolygon")) + .Select(l => new { l.Id, l.Locality, l.Geometry }) .ToListAsync(cancellationToken); if (locations.Count == 0) return []; @@ -670,12 +674,7 @@ public async Task> GetLocationPolygonsAsync(Loca foreach (var location in locations) { - var geo = location.Geometry; - if (geo == null) continue; - - // Only include Polygon and MultiPolygon — skip Point, LineString, etc. - if (geo.GeometryType != "Polygon" && geo.GeometryType != "MultiPolygon") continue; - + var geo = location.Geometry!; var wkt = geo.AsText(); if (IsRectangularPolygon(wkt)) continue;