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/Constants/SearchConstants.cs b/Artskart3.Core/Constants/SearchConstants.cs index ca87ec69..b4663bbe 100644 --- a/Artskart3.Core/Constants/SearchConstants.cs +++ b/Artskart3.Core/Constants/SearchConstants.cs @@ -14,6 +14,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.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 6ce3c1d0..fc34a8c5 100644 --- a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs +++ b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs @@ -692,4 +692,90 @@ private List FilterAreasBySelection(List areas, LocationSearchFilter ).ToList(); } + /// + /// 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) + { + try + { + filter ??= new LocationSearchFilterDto(); + + var query = BuildLocationsQuery(filter); + 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 []; + + 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.GeometryType == "Polygon" || l.Geometry.GeometryType == "MultiPolygon")) + .Select(l => new { l.Id, l.Locality, l.Geometry }) + .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!; + 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 = wkt, + 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 WKT string represents a rectangular polygon (exactly 5 coordinate pairs in the exterior ring). + /// + private static bool IsRectangularPolygon(string? wkt) + { + 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.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); + } } 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 4bf3a39c..c688176c 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'; @@ -304,6 +305,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å. @@ -348,6 +350,52 @@ export class AreasService { ); } + /** + * Fetches location polygon geometries and returns as a GeoJSON FeatureCollection string. + */ + 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 => { + 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 15a18262..c44577d4 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; @@ -285,6 +286,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 { @@ -328,14 +338,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(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); + 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; +}