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;
+}