Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Artskart3.Api/Controllers/SearchController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,37 @@ private bool IsValidCoordinatePrecisionRange(int from, int to)
/// </summary>
private object CreateRangeErrorMessage(int min, int max)
=> new { error = $"Value must be between {min} and {max}." };

/// <summary>
/// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hvorfor ekskluderer vi rektangler/kvadrat? Er ikke disse sensitive funn som også skal vises?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ekskluder skjulte funn, rektangler/kvadrat, litt usikker skal sjekke

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ser ikke noen grunn til å ekskludere skjulte funn. De skal vises de også

/// </summary>
[HttpPost("LocationPolygons")]
[Produces("application/json")]
[ProducesResponseType(typeof(IEnumerable<LocationPolygonDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IEnumerable<LocationPolygonDto>>> GetLocationPolygons(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kanskje greit å sende med envelopen som vi gjør med punkter? Da henter vi ikke alle polygonene, men bare de som vises på skjermen.

[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;
}
}
}
12 changes: 12 additions & 0 deletions Artskart3.Core/Application/DTOs/LocationPolygonDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Artskart3.Core.Application.DTOs;

/// <summary>
/// Represents a location's polygon geometry.
/// </summary>
public class LocationPolygonDto
{
public int LocationId { get; set; }
public string? Locality { get; set; }
public string WktPolygon { get; set; } = null!;
public int ObservationCount { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,9 @@ public async Task<IEnumerable<AreaMarkerDto>> GetAreaMarkersAsync(int zoomLevel,

return await _searchRepository.GetAreaMarkersAsync(zoomLevel, filter, cancellationToken);
}

public async Task<IEnumerable<LocationPolygonDto>> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default)
{
return await _searchRepository.GetLocationPolygonsAsync(filter, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public interface ISearchService
Task<List<ObservationDto>> GetObservationsAsync(ObservationSearchFilterDto filter, CancellationToken cancellationToken = default);

Task<IEnumerable<AreaMarkerDto>> GetAreaMarkersAsync(int zoomLevel, LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default);
Task<IEnumerable<LocationPolygonDto>> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default);
}
4 changes: 4 additions & 0 deletions Artskart3.Core/Constants/SearchConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public interface ISearchRepository
IAsyncEnumerable<LocationModel> GetLocationsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default);
Task<List<ObservationDto>> GetObservationsAsync(ObservationSearchFilterDto filter, CancellationToken cancellationToken = default);
Task<IEnumerable<AreaMarkerDto>> GetAreaMarkersAsync(int zoomLevel, LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default);
Task<IEnumerable<LocationPolygonDto>> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -692,4 +692,90 @@ private List<Area> FilterAreasBySelection(List<Area> areas, LocationSearchFilter
).ToList();
}

/// <summary>
/// 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.
/// </summary>
public async Task<IEnumerable<LocationPolygonDto>> GetLocationPolygonsAsync(LocationSearchFilterDto? filter = null, CancellationToken cancellationToken = default)
{
try
{
filter ??= new LocationSearchFilterDto();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vi burde kanskje være litt mer aggresive på antall resultater for polygoner. Vil tro 100 000 polygoner er litt mye, så vi kunne ha hatt lavere maxresults for disse?


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<Location>()
.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);

@kennethaamodt kennethaamodt Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hent ut kun felter vi trenger:
.Select(l => new { l.Id, l.Locality, l.Geometry })

Da kan vi også forenkle foreach loopen litt:

foreach (var location in locations)
{
    if (IsAxisAlignedRectangle(location.Geometry!)) 
    {
        continue;
    }

    result.Add(new LocationPolygonDto
                {
                    LocationId = location.Id,
                    Locality = location.Locality,
                    WktPolygon = location.Geometry!.AsText(),
                    ObservationCount = countLookup.GetValueOrDefault(location.Id)
                });
}


if (locations.Count == 0) return [];

var countLookup = aggregated.ToDictionary(x => x.LocationId, x => x.ObservationCount);

var result = new List<LocationPolygonDto>();

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

/// <summary>
/// Returns true when the WKT string represents a rectangular polygon (exactly 5 coordinate pairs in the exterior ring).
/// </summary>
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;
}

}
26 changes: 26 additions & 0 deletions Artskart3.Tests.Integration/Tests/SearchEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
48 changes: 48 additions & 0 deletions Artskart3.WebApp/src/app/core/services/areas/areas.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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å.
Expand Down Expand Up @@ -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<string> {
const body = this.buildFilterBody(filter, extent);

return this.apiClientService.postJson<LocationPolygonDto[]>(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<string, unknown> {
const body: Record<string, unknown> = {};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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$),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}