From f4ddfce9d4ec446f8e0ee86ec901c0de212c2cc1 Mon Sep 17 00:00:00 2001 From: Andreas Wist Date: Thu, 25 Jun 2026 17:00:13 +0200 Subject: [PATCH 1/3] add species search with autocomplete and active filter chips - Add species search component with debounced autocomplete dropdown - Show vernacular name, scientific name, author, and rank in results - Highlight matching text in autocomplete results - Add filter chips displaying active filters with clear actions - Add TaxonIds support to observation search filter - Add translations for rank names and filter chip labels --- Artskart3.Api/Controllers/SearchController.cs | 2 + .../Application/DTOs/IObservationFilter.cs | 1 + .../DTOs/LocationSearchFilterDto.cs | 4 + .../DTOs/ObservationSearchFilterDto.cs | 2 + .../Repositories/SearchRepository.cs | 6 + .../app/core/services/areas/areas.service.ts | 124 +++++----- .../filter-chips/filter-chips.component.css | 22 ++ .../filter-chips/filter-chips.component.html | 14 ++ .../filter-chips.component.spec.ts | 143 ++++++++++++ .../filter-chips/filter-chips.component.ts | 90 ++++++++ .../list-view/list-view.component.ts | 1 + .../components/map.component/map.component.ts | 87 +++----- .../resizable-panel.component.ts | 12 +- .../components/sidebar/sidebar.component.css | 4 - .../components/sidebar/sidebar.component.html | 6 +- .../sidebar/sidebar.component.spec.ts | 6 +- .../components/sidebar/sidebar.component.ts | 4 +- .../species-search.component.css | 63 ++++++ .../species-search.component.html | 44 ++++ .../species-search.component.spec.ts | 155 +++++++++++++ .../species-search.component.ts | 118 ++++++++++ .../filter-state/filter-state.service.ts | 20 ++ .../species-search.service.spec.ts | 45 ++++ .../species-search/species-search.service.ts | 16 ++ .../src/app/shared/types/api.generated.ts | 211 ++++++++++++++++-- .../src/app/shared/types/api.types.ts | 3 + Artskart3.WebApp/src/assets/languages/en.json | 33 ++- Artskart3.WebApp/src/assets/languages/no.json | 33 ++- Artskart3.WebApp/src/styles.css | 46 ++-- 29 files changed, 1149 insertions(+), 166 deletions(-) create mode 100644 Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css create mode 100644 Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html create mode 100644 Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts create mode 100644 Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts create mode 100644 Artskart3.WebApp/src/app/shared/components/species-search/species-search.component.css create mode 100644 Artskart3.WebApp/src/app/shared/components/species-search/species-search.component.html create mode 100644 Artskart3.WebApp/src/app/shared/components/species-search/species-search.component.spec.ts create mode 100644 Artskart3.WebApp/src/app/shared/components/species-search/species-search.component.ts create mode 100644 Artskart3.WebApp/src/app/shared/services/species-search/species-search.service.spec.ts create mode 100644 Artskart3.WebApp/src/app/shared/services/species-search/species-search.service.ts diff --git a/Artskart3.Api/Controllers/SearchController.cs b/Artskart3.Api/Controllers/SearchController.cs index b1465e65..7e607fd1 100644 --- a/Artskart3.Api/Controllers/SearchController.cs +++ b/Artskart3.Api/Controllers/SearchController.cs @@ -277,6 +277,7 @@ private bool ValidateFilterArraySizes(ObservationSearchFilterDto filter, out Bad ReadOnlySpan<(string name, int? length)> arrays = [ (nameof(filter.TaxonGroupIds), filter.TaxonGroupIds?.Length), + (nameof(filter.TaxonIds), filter.TaxonIds?.Length), (nameof(filter.CategoryIds), filter.CategoryIds?.Length), (nameof(filter.OrganizationIds), filter.OrganizationIds?.Length), (nameof(filter.MunicipalityIds), filter.MunicipalityIds?.Length), @@ -343,6 +344,7 @@ private bool ValidateLocationFilterArraySizes(LocationSearchFilterDto filter, ou ReadOnlySpan<(string name, int? length)> arrays = [ (nameof(filter.TaxonGroupIds), filter.TaxonGroupIds?.Length), + (nameof(filter.TaxonIds), filter.TaxonIds?.Length), (nameof(filter.CategoryIds), filter.CategoryIds?.Length), (nameof(filter.OrganizationIds), filter.OrganizationIds?.Length), (nameof(filter.MunicipalityIds), filter.MunicipalityIds?.Length), diff --git a/Artskart3.Core/Application/DTOs/IObservationFilter.cs b/Artskart3.Core/Application/DTOs/IObservationFilter.cs index ef088e34..bba97b29 100644 --- a/Artskart3.Core/Application/DTOs/IObservationFilter.cs +++ b/Artskart3.Core/Application/DTOs/IObservationFilter.cs @@ -7,6 +7,7 @@ namespace Artskart3.Core.Application.DTOs; public interface IObservationFilter { int[]? TaxonGroupIds { get; } + int[]? TaxonIds { get; } int[]? CategoryIds { get; } int[]? OrganizationIds { get; } string[]? MunicipalityIds { get; } diff --git a/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs b/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs index 2c517fb7..b1f2831e 100644 --- a/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs +++ b/Artskart3.Core/Application/DTOs/LocationSearchFilterDto.cs @@ -4,6 +4,8 @@ public class LocationSearchFilterDto : IObservationFilter { public int[]? TaxonGroupIds { get; set; } + public int[]? TaxonIds { get; set; } + public int[]? CategoryIds { get; set; } public int[]? BasisOfRecordIds { get; set; } @@ -29,6 +31,7 @@ public class LocationSearchFilterDto : IObservationFilter /// public bool HasActiveFilters => TaxonGroupIds?.Length > 0 || + TaxonIds?.Length > 0 || CategoryIds?.Length > 0 || BasisOfRecordIds?.Length > 0 || OrganizationIds?.Length > 0 || @@ -50,6 +53,7 @@ public class LocationSearchFilterDto : IObservationFilter /// public bool HasObservationAttributeFilters => TaxonGroupIds?.Length > 0 || + TaxonIds?.Length > 0 || CategoryIds?.Length > 0 || BasisOfRecordIds?.Length > 0 || OrganizationIds?.Length > 0 || diff --git a/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs b/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs index 05e8e875..a51f8c04 100644 --- a/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs +++ b/Artskart3.Core/Application/DTOs/ObservationSearchFilterDto.cs @@ -10,6 +10,8 @@ public class ObservationSearchFilterDto : PaginatedRequestDto, IObservationFilte public int[]? TaxonGroupIds { get; set; } + public int[]? TaxonIds { get; set; } + public int[]? CategoryIds { get; set; } public int[]? OrganizationIds { get; set; } diff --git a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs index 204f0a43..13345760 100644 --- a/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs +++ b/Artskart3.Infrastructure/Persistence/Repositories/SearchRepository.cs @@ -273,6 +273,12 @@ private IQueryable ApplyCommonFilters(IQueryable query query = query.Where(o => taxonGroupIds.Contains(o.TaxonGroupId)); } + if (filter.TaxonIds?.Any() == true) + { + var taxonIds = filter.TaxonIds.ToList(); + query = query.Where(o => taxonIds.Contains(o.TaxonId)); + } + if (filter.CategoryIds?.Any() == true) { var categoryIds = filter.CategoryIds.ToList(); 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..ca15efef 100644 --- a/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts +++ b/Artskart3.WebApp/src/app/core/services/areas/areas.service.ts @@ -11,10 +11,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { - AreaMarkerDto, - AreaMarkerFeature, -} from '@shared/models/area/area-marker.model'; +import { AreaMarkerDto, AreaMarkerFeature } from '@shared/models/area/area-marker.model'; import { AbbreviateNumberHelper } from '@shared/helpers/number/abbreviate-number.helper'; import { ZoomConfig } from '@shared/helpers/zoom/zoom-config'; import { ApiClientService } from '../api-client.service'; @@ -31,7 +28,7 @@ const NBIC_LOCATION_STYLE = { fillColor: '#005A71', strokeColor: '#D2DDE0', strokeWidth: 2, - } + }, }; interface ParsedGeometry { @@ -44,10 +41,10 @@ interface ParsedGeometry { * @example "1 2, 3 4, 5 6, 1 2" => [[1, 2], [3, 4], [5, 6], [1, 2]] */ function parseRing(ringStr: string): number[][] | null { - const points = ringStr.split(',').map(p => p.trim()); + const points = ringStr.split(',').map((p) => p.trim()); const coordinates = points - .map(point => { - const nums = point.split(/\s+/).map(n => parseFloat(n)); + .map((point) => { + const nums = point.split(/\s+/).map((n) => parseFloat(n)); if (nums.length !== 2 || isNaN(nums[0]) || isNaN(nums[1])) { return null; } @@ -117,21 +114,37 @@ function clipRingToExtent(ring: number[][], extent: [number, number, number, num let output = ring; const edges: { inside: (p: number[]) => boolean; intersect: (a: number[], b: number[]) => number[] }[] = [ - { // Venstre (x >= minX) + { + // Venstre (x >= minX) inside: (p) => p[0] >= extent[0], - intersect: (a, b) => { const t = (extent[0] - a[0]) / (b[0] - a[0]); return [extent[0], a[1] + t * (b[1] - a[1])]; } + intersect: (a, b) => { + const t = (extent[0] - a[0]) / (b[0] - a[0]); + return [extent[0], a[1] + t * (b[1] - a[1])]; + }, }, - { // Høyre (x <= maxX) + { + // Høyre (x <= maxX) inside: (p) => p[0] <= extent[2], - intersect: (a, b) => { const t = (extent[2] - a[0]) / (b[0] - a[0]); return [extent[2], a[1] + t * (b[1] - a[1])]; } + intersect: (a, b) => { + const t = (extent[2] - a[0]) / (b[0] - a[0]); + return [extent[2], a[1] + t * (b[1] - a[1])]; + }, }, - { // Bunn (y >= minY) + { + // Bunn (y >= minY) inside: (p) => p[1] >= extent[1], - intersect: (a, b) => { const t = (extent[1] - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), extent[1]]; } + intersect: (a, b) => { + const t = (extent[1] - a[1]) / (b[1] - a[1]); + return [a[0] + t * (b[0] - a[0]), extent[1]]; + }, }, - { // Topp (y <= maxY) + { + // Topp (y <= maxY) inside: (p) => p[1] <= extent[3], - intersect: (a, b) => { const t = (extent[3] - a[1]) / (b[1] - a[1]); return [a[0] + t * (b[0] - a[0]), extent[3]]; } + intersect: (a, b) => { + const t = (extent[3] - a[1]) / (b[1] - a[1]); + return [a[0] + t * (b[0] - a[0]), extent[3]]; + }, }, ]; @@ -162,7 +175,9 @@ function clipRingToExtent(ring: number[][], extent: [number, number, number, num */ function ringAreaAndCentroid(ring: number[][]): { area: number; cx: number; cy: number } | null { if (ring.length < 3) return null; - let area = 0, cx = 0, cy = 0; + let area = 0, + cx = 0, + cy = 0; for (let i = 0; i < ring.length; i++) { const j = (i + 1) % ring.length; const cross = ring[i][0] * ring[j][1] - ring[j][0] * ring[i][1]; @@ -183,8 +198,7 @@ function pointInRing(point: [number, number], ring: number[][]): boolean { for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const [xi, yi] = ring[i]; const [xj, yj] = ring[j]; - if ((yi > point[1]) !== (yj > point[1]) && - point[0] < (xj - xi) * (point[1] - yi) / (yj - yi) + xi) { + if (yi > point[1] !== yj > point[1] && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) { inside = !inside; } } @@ -235,9 +249,7 @@ function ensureInsideRing(point: [number, number], ring: number[][]): [number, n * en av de klipte polygondelene. */ function calculateClippedCentroid(parsed: ParsedGeometry, extent: [number, number, number, number]): [number, number] | null { - const polygons = parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][]) - : [parsed.coordinates as number[][][]]; + const polygons = parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][]) : [parsed.coordinates as number[][][]]; const clippedParts: { ring: number[][]; area: number; cx: number; cy: number }[] = []; @@ -251,7 +263,9 @@ function calculateClippedCentroid(parsed: ParsedGeometry, extent: [number, numbe if (clippedParts.length === 0) return null; // Arealvektet centroid - let totalArea = 0, weightedX = 0, weightedY = 0; + let totalArea = 0, + weightedX = 0, + weightedY = 0; for (const part of clippedParts) { totalArea += part.area; weightedX += part.cx * part.area; @@ -265,7 +279,7 @@ function calculateClippedCentroid(parsed: ParsedGeometry, extent: [number, numbe } // Ellers: bruk centroid av den største synlige delen, sikret innenfor - const largest = clippedParts.reduce((a, b) => a.area > b.area ? a : b); + const largest = clippedParts.reduce((a, b) => (a.area > b.area ? a : b)); return ensureInsideRing([largest.cx, largest.cy], largest.ring); } @@ -275,6 +289,7 @@ export interface LocationSearchFilter { behaviorIds?: number[]; basisOfRecordIds?: number[]; taxonGroupIds?: number[]; + taxonIds?: number[]; countyIds?: string[]; municipalityIds?: string[]; restrictedAreaIds?: string[]; @@ -286,7 +301,7 @@ export interface LocationSearchFilter { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class AreasService { private static readonly SERVICE_NAME = 'AreasService'; @@ -314,11 +329,14 @@ export class AreasService { return this.apiClientService .postJson(`${this.areasBaseEndpoint}?zoomLevel=${apiZoomLevel}`, body, { responseType: 'text' }) .pipe( - map(responseText => { + map((responseText) => { const areas = this.apiClientService.parseJsonResponse(responseText, AreasService.SERVICE_NAME); - this.loggerService.info(`Retrieved ${Array.isArray(areas) ? areas.length : 0} areas for zoom level ${apiZoomLevel}`, AreasService.SERVICE_NAME); + this.loggerService.info( + `Retrieved ${Array.isArray(areas) ? areas.length : 0} areas for zoom level ${apiZoomLevel}`, + AreasService.SERVICE_NAME, + ); return Array.isArray(areas) ? areas : []; - }) + }), ); } @@ -337,7 +355,7 @@ export class AreasService { const features = this.mapLocationsToGeoJson(parsed); this.loggerService.info(`Retrieved ${features.length} location features`, AreasService.SERVICE_NAME); return JSON.stringify({ type: 'FeatureCollection', features }); - }) + }), ); } @@ -350,6 +368,7 @@ export class AreasService { if (filter.behaviorIds?.length) body['behaviorIds'] = filter.behaviorIds; if (filter.basisOfRecordIds?.length) body['basisOfRecordIds'] = filter.basisOfRecordIds; if (filter.taxonGroupIds?.length) body['taxonGroupIds'] = filter.taxonGroupIds; + if (filter.taxonIds?.length) body['taxonIds'] = filter.taxonIds; if (filter.countyIds?.length) body['countyIds'] = filter.countyIds; if (filter.municipalityIds?.length) body['municipalityIds'] = filter.municipalityIds; if (filter.restrictedAreaIds?.length) body['restrictedAreaIds'] = filter.restrictedAreaIds; @@ -379,9 +398,7 @@ export class AreasService { return []; } - return locations - .map(location => this.createLocationFeature(location)) - .filter((f): f is AreaMarkerFeature => f !== null); + return locations.map((location) => this.createLocationFeature(location)).filter((f): f is AreaMarkerFeature => f !== null); } private normalizeLocationResponse(response: unknown): Record[] { @@ -421,8 +438,8 @@ export class AreasService { observationCountDisplay: observationCount ? AbbreviateNumberHelper.format(observationCount) : '', isPolygon: false, ...(taxonId && { taxonId }), - ...NBIC_LOCATION_STYLE - } + ...NBIC_LOCATION_STYLE, + }, }; } catch { return null; @@ -466,26 +483,22 @@ export class AreasService { const formattedCount = count > 0 ? AbbreviateNumberHelper.format(count) : ''; // Bruk DB-centroid når hele området er synlig, ellers beregn centroid av synlig del - const fullyVisible = bbox[0] >= extent[0] && bbox[1] >= extent[1] - && bbox[2] <= extent[2] && bbox[3] <= extent[3]; + const fullyVisible = bbox[0] >= extent[0] && bbox[1] >= extent[1] && bbox[2] <= extent[2] && bbox[3] <= extent[3]; let centroid: [number, number]; if (fullyVisible) { centroid = area.centroid ? [area.centroid.x, area.centroid.y] : this.calculateCentroid( - parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][])[0][0] - : (parsed.coordinates as number[][][])[0] + parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][])[0][0] : (parsed.coordinates as number[][][])[0], ); } else { - centroid = calculateClippedCentroid(parsed, extent) - ?? (area.centroid + centroid = + calculateClippedCentroid(parsed, extent) ?? + (area.centroid ? [area.centroid.x, area.centroid.y] : this.calculateCentroid( - parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][])[0][0] - : (parsed.coordinates as number[][][])[0] + parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][])[0][0] : (parsed.coordinates as number[][][])[0], )); } @@ -502,8 +515,8 @@ export class AreasService { strokeColor: 'rgba(10, 109, 188, 0.6)', strokeWidth: 1.5, fillColor: 'rgba(0, 0, 0, 0)', - } - } + }, + }, }); // Centroid marker feature with circle + count label @@ -525,9 +538,9 @@ export class AreasService { label: formattedCount, font: 'bold 10px Arial', fillColor: '#FFFFFF', - } - } - } + }, + }, + }, }); } @@ -537,7 +550,8 @@ export class AreasService { private calculateCentroid(ring: number[][]): [number, number] { if (!ring || ring.length === 0) return [0, 0]; - let x = 0, y = 0; + let x = 0, + y = 0; for (const coord of ring) { x += coord[0]; y += coord[1]; @@ -546,10 +560,12 @@ export class AreasService { } private computeBbox(parsed: ParsedGeometry): [number, number, number, number] { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - const rings = parsed.type === 'MultiPolygon' - ? (parsed.coordinates as number[][][][]).flatMap(p => p) - : (parsed.coordinates as number[][][]); + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + const rings = + parsed.type === 'MultiPolygon' ? (parsed.coordinates as number[][][][]).flatMap((p) => p) : (parsed.coordinates as number[][][]); for (const ring of rings) { for (const [x, y] of ring) { if (x < minX) minX = x; diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css new file mode 100644 index 00000000..3ef73c7f --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.css @@ -0,0 +1,22 @@ +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: var(--adb-spacing-3xsm, 4px); + padding: 0 var(--adb-spacing-sm, 12px); +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: var(--adb-spacing-3xsm, 4px); + padding: var(--adb-spacing-3xsm, 4px) var(--adb-spacing-xsm, 8px); + border: 2px solid var(--adb-border-accent, #005a71); + border-radius: var(--adb-border-radius); + font-size: var(--adb-font-size-sm, 0.875rem); + color: var(--adb-text-default, inherit); + white-space: nowrap; +} + +.filter-chip-label { + font-weight: 600; +} diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html new file mode 100644 index 00000000..e7d41360 --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.html @@ -0,0 +1,14 @@ +@if (chips().length > 0) { +
+ @for (chip of chips(); track chip.label) { + + {{ chip.text }} + + + + + + + } +
+} diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts new file mode 100644 index 00000000..b09a7be0 --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.spec.ts @@ -0,0 +1,143 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { FilterChipsComponent } from './filter-chips.component'; +import { FilterStateService } from '../../services/filter-state/filter-state.service'; + +describe('FilterChipsComponent', () => { + let component: FilterChipsComponent; + let fixture: ComponentFixture; + let filterState: FilterStateService; + let translate: TranslateService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilterChipsComponent, TranslateModule.forRoot()], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + + fixture = TestBed.createComponent(FilterChipsComponent); + component = fixture.componentInstance; + filterState = TestBed.inject(FilterStateService); + translate = TestBed.inject(TranslateService); + translate.setDefaultLang('no'); + translate.use('no'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show no chips when no filters are active', () => { + expect(component.chips().length).toBe(0); + }); + + it('should show a chip when taxon groups are selected', () => { + filterState.addTaxonGroup(1); + filterState.addTaxonGroup(2); + const chips = component.chips(); + const taxonChip = chips.find((c) => c.text.includes('(2)')); + expect(taxonChip).toBeTruthy(); + }); + + it('should show a chip when categories are selected', () => { + filterState.addCategory(10); + const chips = component.chips(); + expect(chips.length).toBeGreaterThan(0); + expect(chips.some((c) => c.text.includes('(1)'))).toBe(true); + }); + + it('should show a chip for areas (municipalities + ocean areas)', () => { + filterState.addMunicipality('0301'); + filterState.addMunicipality('0302'); + filterState.toggleOceanArea('ocean1'); + const chips = component.chips(); + const areaChip = chips.find((c) => c.text.includes('(3)')); + expect(areaChip).toBeTruthy(); + }); + + it('should show a chip when taxons (species) are selected', () => { + filterState.addTaxon(1234); + const chips = component.chips(); + expect(chips.some((c) => c.text.includes('(1)'))).toBe(true); + }); + + it('should show a chip for coordinate precision', () => { + filterState.setCoordinatePrecision(0, 500); + const chips = component.chips(); + const precChip = chips.find((c) => c.label === 'sidebar.coordinatePrecision'); + expect(precChip).toBeTruthy(); + }); + + it('should show a chip for period', () => { + filterState.setPeriod(1990, 2020); + const chips = component.chips(); + const periodChip = chips.find((c) => c.label === 'sidebar.period'); + expect(periodChip).toBeTruthy(); + }); + + it('should clear taxon groups when chip close is called', () => { + filterState.addTaxonGroup(1); + filterState.addTaxonGroup(2); + const chips = component.chips(); + const taxonChip = chips.find((c) => c.text.includes('(2)')); + taxonChip!.clear(); + expect(filterState.selectedTaxonGroupIds().length).toBe(0); + }); + + it('should clear categories when chip close is called', () => { + filterState.addCategory(10); + filterState.addCategory(11); + const chips = component.chips(); + const catChip = chips.find((c) => c.text.includes('(2)')); + catChip!.clear(); + expect(filterState.selectedCategoryIds().length).toBe(0); + }); + + it('should clear coordinate precision when chip close is called', () => { + filterState.setCoordinatePrecision(0, 500); + const chips = component.chips(); + const precChip = chips.find((c) => c.label === 'sidebar.coordinatePrecision'); + precChip!.clear(); + expect(filterState.coordinatePrecisionFrom()).toBeNull(); + expect(filterState.coordinatePrecisionTo()).toBeNull(); + }); + + it('should clear period when chip close is called', () => { + filterState.setPeriod(1990, 2020); + const chips = component.chips(); + const periodChip = chips.find((c) => c.label === 'sidebar.period'); + periodChip!.clear(); + expect(filterState.periodFrom()).toBeNull(); + expect(filterState.periodTo()).toBeNull(); + }); + + it('should clear taxons when chip close is called', () => { + filterState.addTaxon(1234); + filterState.addTaxon(5678); + const chips = component.chips(); + const taxonChip = chips.find((c) => c.text.includes('(2)')); + taxonChip!.clear(); + expect(filterState.selectedTaxonIds().length).toBe(0); + }); + + it('should update chip text when language changes', () => { + filterState.addTaxonGroup(1); + component.chips(); // trigger initial evaluation + + translate.use('en'); + fixture.detectChanges(); + + const chipsAfter = component.chips(); + // Text should change (different translation) + // With TranslateModule.forRoot() and no translations loaded, + // keys are returned as-is, but the signal should still re-evaluate + expect(chipsAfter.length).toBe(1); + // The computed should have been triggered (currentLang changed) + expect(chipsAfter[0]).toBeTruthy(); + }); +}); diff --git a/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts new file mode 100644 index 00000000..d1e94ee1 --- /dev/null +++ b/Artskart3.WebApp/src/app/shared/components/filter-chips/filter-chips.component.ts @@ -0,0 +1,90 @@ +import { Component, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TranslateService } from '@ngx-translate/core'; +import { FilterStateService } from '../../services/filter-state/filter-state.service'; + +export interface FilterChip { + label: string; + text: string; + clear: () => void; +} + +@Component({ + selector: 'app-filter-chips', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './filter-chips.component.html', + styleUrl: './filter-chips.component.css', +}) +export class FilterChipsComponent { + private readonly filterState = inject(FilterStateService); + private readonly translate = inject(TranslateService); + private readonly currentLang = signal(this.translate.currentLang || this.translate.defaultLang); + + constructor() { + this.translate.onLangChange.pipe(takeUntilDestroyed()).subscribe((event) => { + this.currentLang.set(event.lang); + }); + } + + readonly chips = computed((): FilterChip[] => { + this.currentLang(); + const chips: FilterChip[] = []; + const taxonGroups = this.filterState.selectedTaxonGroupIds(); + if (taxonGroups.length > 0) { + const label = this.translate.instant('sidebar.taxonGroups'); + chips.push({ label, text: `${label} (${taxonGroups.length})`, clear: () => this.filterState.clearTaxonGroups() }); + } + const categories = this.filterState.selectedCategoryIds(); + if (categories.length > 0) { + const label = this.translate.instant('sidebar.categories'); + chips.push({ label, text: `${label} (${categories.length})`, clear: () => this.filterState.clearCategories() }); + } + const municipalities = this.filterState.selectedMunicipalityIds(); + const oceanAreas = this.filterState.selectedOceanAreaIds(); + const areaCount = municipalities.length + oceanAreas.length; + if (areaCount > 0) { + const label = this.translate.instant('sidebar.areas'); + chips.push({ label, text: `${label} (${areaCount})`, clear: () => this.filterState.clearAreas() }); + } + const institutions = this.filterState.selectedInstitutionIds(); + if (institutions.length > 0) { + const label = this.translate.instant('sidebar.institutions'); + chips.push({ label, text: `${label} (${institutions.length})`, clear: () => this.filterState.clearInstitutions() }); + } + const behaviors = this.filterState.selectedBehaviorIds(); + if (behaviors.length > 0) { + const label = this.translate.instant('sidebar.behaviors'); + chips.push({ label, text: `${label} (${behaviors.length})`, clear: () => this.filterState.clearBehaviors() }); + } + const basisOfRecords = this.filterState.selectedBasisOfRecordIds(); + if (basisOfRecords.length > 0) { + const label = this.translate.instant('sidebar.basisOfRecords'); + chips.push({ label, text: `${label} (${basisOfRecords.length})`, clear: () => this.filterState.clearBasisOfRecords() }); + } + const taxons = this.filterState.selectedTaxonIds(); + if (taxons.length > 0) { + const label = this.translate.instant('sidebar.species'); + chips.push({ label, text: `${label} (${taxons.length})`, clear: () => this.filterState.clearTaxons() }); + } + const precFrom = this.filterState.coordinatePrecisionFrom(); + const precTo = this.filterState.coordinatePrecisionTo(); + if (precFrom != null || precTo != null) { + const label = this.translate.instant('sidebar.coordinatePrecision'); + const from = precFrom ?? 0; + const to = precTo != null ? String(precTo) : '∞'; + const text = this.translate.instant('sidebar.chipCoordinatePrecision', { from, to }); + chips.push({ label, text, clear: () => this.filterState.clearCoordinatePrecision() }); + } + const periodFrom = this.filterState.periodFrom(); + const periodTo = this.filterState.periodTo(); + if (periodFrom != null || periodTo != null) { + const label = this.translate.instant('sidebar.period'); + const from = periodFrom != null ? String(periodFrom) : '...'; + const to = periodTo != null ? String(periodTo) : '...'; + const text = this.translate.instant('sidebar.chipPeriod', { from, to }); + chips.push({ label, text, clear: () => this.filterState.clearPeriod() }); + } + return chips; + }); +} diff --git a/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts b/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts index 9cf2daf0..40946f02 100644 --- a/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/list-view/list-view.component.ts @@ -109,6 +109,7 @@ export class ListViewComponent { behaviorIds: this.filterState.selectedBehaviorIds().length ? this.filterState.selectedBehaviorIds() : undefined, basisOfRecordIds: this.filterState.selectedBasisOfRecordIds().length ? this.filterState.selectedBasisOfRecordIds() : undefined, taxonGroupIds: this.filterState.selectedTaxonGroupIds().length ? this.filterState.selectedTaxonGroupIds() : undefined, + taxonIds: this.filterState.selectedTaxonIds().length ? this.filterState.selectedTaxonIds() : undefined, countyIds: countyIds.length ? countyIds : undefined, municipalityIds: municipalityIds.length ? municipalityIds : undefined, oceanAreaIds: this.filterState.selectedOceanAreaIds().length ? this.filterState.selectedOceanAreaIds() : undefined, 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 078bdb1c..fafe1aa4 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 @@ -1,21 +1,5 @@ -import { - createMap, - MapEvents, - NbicMapComponent, - nbicMapPresets, -} from '@artsdatabanken/nbic-map-component'; -import { - AfterViewInit, - Component, - ElementRef, - Output, - EventEmitter, - ViewChild, - OnDestroy, - inject, - computed, - effect, -} from '@angular/core'; +import { createMap, MapEvents, NbicMapComponent, nbicMapPresets } from '@artsdatabanken/nbic-map-component'; +import { AfterViewInit, Component, ElementRef, Output, EventEmitter, ViewChild, OnDestroy, inject, computed, effect } from '@angular/core'; import { LoggingService } from '@shared/logging.service'; import { Subject, EMPTY } from 'rxjs'; import { catchError, debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators'; @@ -74,6 +58,7 @@ export class MapComponent implements AfterViewInit, OnDestroy { behaviorIds: this.filterState.selectedBehaviorIds().length ? this.filterState.selectedBehaviorIds() : undefined, basisOfRecordIds: this.filterState.selectedBasisOfRecordIds().length ? this.filterState.selectedBasisOfRecordIds() : undefined, taxonGroupIds: this.filterState.selectedTaxonGroupIds().length ? this.filterState.selectedTaxonGroupIds() : undefined, + taxonIds: this.filterState.selectedTaxonIds().length ? this.filterState.selectedTaxonIds() : undefined, countyIds: countyIds.length ? countyIds : undefined, municipalityIds: municipalityIds.length ? municipalityIds : undefined, oceanAreaIds: this.filterState.selectedOceanAreaIds().length ? this.filterState.selectedOceanAreaIds() : undefined, @@ -218,47 +203,49 @@ export class MapComponent implements AfterViewInit, OnDestroy { } private setupAreaDataPipeline(): void { - this.fetchAreaData$.pipe( - debounceTime(300), - switchMap(({ apiZoomLevel, olZoom, extent }) => { - const isLocationPoints = apiZoomLevel === ApiZoomLevel.LocationPoints; + this.fetchAreaData$ + .pipe( + debounceTime(300), + switchMap(({ apiZoomLevel, olZoom, extent }) => { + const isLocationPoints = apiZoomLevel === ApiZoomLevel.LocationPoints; - // For områdemarkører: bruk cachet data og bygg GeoJSON med gjeldende kartutsnitt - if (!isLocationPoints) { - const cachedAreas = this.areaDataCacheByApiZoom.get(apiZoomLevel); - if (cachedAreas) { - const geojson = this.areasService.buildAreaGeoJson(cachedAreas, extent); - this.applyGeoJsonToLayer(apiZoomLevel, geojson); - return EMPTY; + // For områdemarkører: bruk cachet data og bygg GeoJSON med gjeldende kartutsnitt + if (!isLocationPoints) { + const cachedAreas = this.areaDataCacheByApiZoom.get(apiZoomLevel); + if (cachedAreas) { + const geojson = this.areasService.buildAreaGeoJson(cachedAreas, extent); + this.applyGeoJsonToLayer(apiZoomLevel, geojson); + return EMPTY; + } + + const filter = this.locationFilter(); + return this.areasService.getAreaMarkers(olZoom, filter).pipe( + tap((areas) => { + this.areaDataCacheByApiZoom.set(apiZoomLevel, areas); + const geojson = this.areasService.buildAreaGeoJson(areas, extent); + this.applyGeoJsonToLayer(apiZoomLevel, geojson); + }), + catchError((err: unknown) => { + this.logger.error(`Failed to load area markers for API zoom level ${apiZoomLevel}:`, 'MapComponent', err); + return EMPTY; + }), + ); } const filter = this.locationFilter(); - return this.areasService.getAreaMarkers(olZoom, filter).pipe( - tap(areas => { - this.areaDataCacheByApiZoom.set(apiZoomLevel, areas); - const geojson = this.areasService.buildAreaGeoJson(areas, extent); + return this.areasService.getLocationsAsGeoJsonString(extent, filter).pipe( + tap((geojson) => { this.applyGeoJsonToLayer(apiZoomLevel, geojson); }), catchError((err: unknown) => { - this.logger.error(`Failed to load area markers for API zoom level ${apiZoomLevel}:`, 'MapComponent', err); + this.logger.error(`Failed to load location points:`, 'MapComponent', err); return EMPTY; - }) + }), ); - } - - 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; - }) - ); - }), - takeUntil(this.destroy$), - ).subscribe(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); } private applyGeoJsonToLayer(apiZoomLevel: number, geojson: string): void { diff --git a/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts b/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts index 5dd3e3af..16f89824 100644 --- a/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts +++ b/Artskart3.WebApp/src/app/shared/components/resizable-panel/resizable-panel.component.ts @@ -1,12 +1,4 @@ -import { - Component, - Input, - Output, - EventEmitter, - HostListener, - signal, - OnInit, -} from '@angular/core'; +import { Component, Input, Output, EventEmitter, HostListener, signal, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; @@ -18,7 +10,7 @@ import { TranslateModule } from '@ngx-translate/core'; styleUrl: './resizable-panel.component.css', }) export class ResizablePanelComponent implements OnInit { - @Input() initialWidth = 300; + @Input() initialWidth = 358; @Input() minWidth = 200; @Input() maxWidth = 500; @Input() isDraggable = true; diff --git a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css index 017c3ac7..edc5efb0 100644 --- a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css +++ b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.css @@ -17,10 +17,6 @@ } } -.search-field { - display: none; -} - /* Remove default beige border from all accordions in sidebar */ adb-accordion { --adb-border-default: transparent; diff --git a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html index 29da70bf..74b4e3bc 100644 --- a/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html +++ b/Artskart3.WebApp/src/app/shared/components/sidebar/sidebar.component.html @@ -1,12 +1,12 @@