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
292 changes: 265 additions & 27 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@
"@ngx-translate/core": "^16.0.4",
"@ngx-translate/http-loader": "^16.0.1",
"@openid/appauth": "^1.3.1",
"@placemarkio/check-geojson": "^0.1.14",
"@sentry/browser": "^7.37.2",
"@tmcw/togeojson": "^7.1.2",
"@turf/clean-coords": "^7.2.0",
"@turf/truncate": "^7.2.0",
"@turf/turf": "^7.1.0",
"@types/leaflet-draw": "^1.0.6",
"angular-svg-icon": "^16.1.0",
Expand All @@ -106,6 +110,7 @@
"ngx-file-drop": "^16.0.0",
"ngx-logger": "^4.2.1",
"ngx-markdown": "^20.0.0",
"nve-designsystem": "^2.16.0",
"observable-webworker": "^3.4.0",
"pouchdb-adapter-idb": "^7.2.2",
"rxjs": "^7.8.1",
Expand Down
1 change: 1 addition & 0 deletions src/app/core/services/database/database.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class DatabaseService {
* @returns Returns a promise with the value of the given key
*/
async get<T>(key: string): Promise<T> {
// TODO: Legg til null på type
await firstValueFrom(this.ready$);
return this.database.get(key);
}
Expand Down
8 changes: 8 additions & 0 deletions src/app/core/services/geojson/geojson-item.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface GeoJSONItem {
id: string;
name: string;
date: number;
visibleOnMap?: boolean;
comment?: string;
lengthKm?: number;
}
199 changes: 199 additions & 0 deletions src/app/core/services/geojson/geojson.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { GeoJSONService } from './geojson.service';
import { DatabaseService } from '../database/database.service';
import { LoggingService } from 'src/app/modules/shared/services/logging/logging.service';
import { FeatureCollection } from 'geojson';
import { GeoJSONItem } from './geojson-item.model';

describe('GeoJSONService', () => {
let service: GeoJSONService;
let databaseService: jasmine.SpyObj<DatabaseService>;
let loggingService: jasmine.SpyObj<LoggingService>;

const mockGeoJSON: FeatureCollection = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [10.0, 60.0],
},
properties: {
name: 'Test Point',
},
},
],
};

const mockMetadataItem: GeoJSONItem = {
id: 'test-id-1',
name: 'Test GeoJSON',
date: new Date().getTime(),
};

beforeEach(() => {
const databaseServiceSpy = jasmine.createSpyObj('DatabaseService', ['get', 'set', 'remove']);
const loggingServiceSpy = jasmine.createSpyObj('LoggingService', ['debug', 'error']);

TestBed.configureTestingModule({
providers: [
GeoJSONService,
{ provide: DatabaseService, useValue: databaseServiceSpy },
{ provide: LoggingService, useValue: loggingServiceSpy },
],
});

databaseService = TestBed.inject(DatabaseService) as jasmine.SpyObj<DatabaseService>;
loggingService = TestBed.inject(LoggingService) as jasmine.SpyObj<LoggingService>;

// Default spy returns
databaseService.get.and.returnValue(Promise.resolve([]));
databaseService.set.and.returnValue(Promise.resolve());
databaseService.remove.and.returnValue(Promise.resolve());
});

it('should be created', () => {
service = TestBed.inject(GeoJSONService);
expect(service).toBeTruthy();
});

describe('init', () => {
it('should load metadata from database on initialization', fakeAsync(() => {
const existingMetadata: GeoJSONItem[] = [mockMetadataItem];
databaseService.get.and.returnValue(Promise.resolve(existingMetadata));

service = TestBed.inject(GeoJSONService);
tick();

expect(databaseService.get).toHaveBeenCalledWith('geojson-metadata');
expect(service.metadata()).toEqual(existingMetadata);
}));

it('should initialize with empty metadata if none exists', fakeAsync(() => {
databaseService.get.and.returnValue(Promise.resolve(null));

service = TestBed.inject(GeoJSONService);
tick();

expect(service.metadata()).toEqual([]);
}));
});

describe('save', () => {
beforeEach(fakeAsync(() => {
service = TestBed.inject(GeoJSONService);
tick(); // Complete initialization
tick(); // Allow effect to run
databaseService.set.calls.reset();
}));

it('should save geojson and update metadata', fakeAsync(() => {
service.save(mockMetadataItem, mockGeoJSON);
tick();

expect(databaseService.set).toHaveBeenCalledWith(`geojson:${mockMetadataItem.id}`, mockGeoJSON);
expect(service.metadata()).toContain(mockMetadataItem);
}));

it('should save metadata after adding item', fakeAsync(() => {
service.save(mockMetadataItem, mockGeoJSON);
tick();
tick(); // Allow effect to trigger

expect(databaseService.set).toHaveBeenCalledWith('geojson-metadata', [mockMetadataItem]);
}));

it('should emit changed metadata item', fakeAsync(() => {
let emittedMetadata: GeoJSONItem | undefined;
service.changedMetadataItem$.subscribe((metadata) => (emittedMetadata = metadata));

service.save(mockMetadataItem, mockGeoJSON);
tick();

expect(emittedMetadata).toEqual(mockMetadataItem);
}));

it('should throw error if save fails', fakeAsync(() => {
const error = new Error('Save failed');
databaseService.set.and.returnValue(Promise.reject(error));

expectAsync(service.save(mockMetadataItem, mockGeoJSON)).toBeRejectedWith(error);
tick();

expect(loggingService.error).toHaveBeenCalled();
}));
});

describe('get', () => {
beforeEach(fakeAsync(() => {
service = TestBed.inject(GeoJSONService);
tick();
}));

it('should retrieve geojson by id', fakeAsync(() => {
databaseService.get.and.returnValue(Promise.resolve(mockGeoJSON));

service.get(mockMetadataItem.id).then((result) => {
expect(result).toEqual(mockGeoJSON);
});
tick();

expect(databaseService.get).toHaveBeenCalledWith(`geojson:${mockMetadataItem.id}`);
}));
});

describe('remove', () => {
beforeEach(fakeAsync(() => {
service = TestBed.inject(GeoJSONService);
tick();
// Add an item first
service.save(mockMetadataItem, mockGeoJSON);
tick();
databaseService.set.calls.reset();
}));

it('should remove geojson and update metadata', fakeAsync(() => {
service.remove(mockMetadataItem.id);
tick();

expect(databaseService.remove).toHaveBeenCalledWith(`geojson:${mockMetadataItem.id}`);
expect(service.metadata()).not.toContain(mockMetadataItem);
}));

it('should emit removed id', fakeAsync(() => {
let emittedId: string | undefined;
service.removedMetadataItemId$.subscribe((id) => (emittedId = id));

service.remove(mockMetadataItem.id);
tick();

expect(emittedId).toBe(mockMetadataItem.id);
}));

it('should save updated metadata after removal', fakeAsync(() => {
service.remove(mockMetadataItem.id);
tick();
tick(); // Allow effect to trigger

expect(databaseService.set).toHaveBeenCalledWith('geojson-metadata', []);
}));
});

describe('changedMetadataItem$', () => {
beforeEach(fakeAsync(() => {
service = TestBed.inject(GeoJSONService);
tick();
}));

it('should emit metadata changes', fakeAsync(() => {
const emittedValues: GeoJSONItem[] = [];
service.changedMetadataItem$.subscribe((metadata) => emittedValues.push(metadata));

service.updateMetadata(mockMetadataItem);
tick();

expect(emittedValues[emittedValues.length - 1]).toEqual(mockMetadataItem);
}));
});
});
136 changes: 136 additions & 0 deletions src/app/core/services/geojson/geojson.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Injectable, effect, inject, signal } from '@angular/core';
import { DatabaseService } from '../database/database.service';
import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import { GeoJSONItem } from './geojson-item.model';
import { LoggingService } from 'src/app/modules/shared/services/logging/logging.service';
import { cleanFeatureCollection } from 'src/app/pages/plans/geojson';
import { length } from '@turf/turf';
import { Subject } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';

const DEBUG_TAG = 'GeoJSON';

@Injectable({
providedIn: 'root',
})
export class GeoJSONService {
private db = inject(DatabaseService);
private logger = inject(LoggingService);

// tur som skal vises på kartet når bruker klikker på vis i kart knappen i turlisten.
geojsonItemToShowOnMap = signal<L.LatLngBounds | null>(null);
readonly geojsonItemToShowOnMap$ = toObservable(this.geojsonItemToShowOnMap);
private initialized = false;
private metadata_ = signal<GeoJSONItem[]>([]);
private changedMetadataItem = new Subject<GeoJSONItem>();
private removedMetadataItemId = new Subject<string>();

/** Metadata for alle lagrede spor */
metadata = this.metadata_.asReadonly();

/** Lytt på denne for å få beskjed om nye eller endrede spor */
changedMetadataItem$ = this.changedMetadataItem.asObservable();

/** Lytt på denne for å få beskjed om slettede spor */
removedMetadataItemId$ = this.removedMetadataItemId.asObservable();

constructor() {
this.init();

effect(() => {
const metadata = this.metadata();
if (!this.initialized) return;
this.saveMetadata(metadata);
});
}

private async init() {
this.logger.debug('Init', DEBUG_TAG);
const items = await this.getMetadata();
if (items && items.length > 0) {
this.metadata_.set(items);
}
setTimeout(() => (this.initialized = true)); // For å unngå en første unødvendig lagring i effecten
}

private async saveMetadata(items: GeoJSONItem[]) {
this.logger.debug('Saving metadata', DEBUG_TAG, { n: items.length });
await this.db.set('geojson-metadata', items);
}

private getMetadata() {
this.logger.debug('Reading metadata', DEBUG_TAG);
return this.db.get<GeoJSONItem[]>('geojson-metadata');
}

/**
* Kalkulerer lengden av LineString features i et GeoJSON objekt
* @param geojson
* @returns lengde i kilometer
*/
private calculateLengthKm(geojson: Feature<Geometry, GeoJsonProperties>[]): number {
return geojson.reduce((sum, feature) => sum + length(feature), 0);
}

/**
* Updates metadata for a given item
* @param item the geojson item to update
*/
updateMetadata(item: GeoJSONItem) {
this.metadata_.update((items) => {
const other = items.filter((x) => x.id !== item.id);
return [...other, item];
});
this.changedMetadataItem.next(item);
}

/**
* Save a geojson object with a given id
* @param metadata metadata for the geojson
* @param geojson geojson object
*/
async save(metadata: GeoJSONItem, geojson: FeatureCollection): Promise<void> {
this.logger.debug('Save', DEBUG_TAG, { metadata });
try {
cleanFeatureCollection(geojson);
} catch (error) {
this.logger.error(error, DEBUG_TAG, 'Error in cleaning process, but object may be mutated - half cleaned');
}

try {
const lineFeatures = geojson.features.filter((f) => f.geometry.type === 'LineString');

if (lineFeatures.length) {
const lengthKm = this.calculateLengthKm(lineFeatures);
metadata.lengthKm = lengthKm;
}

await this.db.set(`geojson:${metadata.id}`, geojson);
this.metadata_.update((items) => [...items, metadata]);
this.changedMetadataItem.next(metadata);
} catch (error) {
this.logger.error(error, DEBUG_TAG, 'Could not save', { metadata, geojson });
throw error;
}
}

/**
* Get a geojson object by id
* @param id unique id for the geojson
*/
async get(id: GeoJSONItem['id']): Promise<FeatureCollection> {
this.logger.debug('Get', DEBUG_TAG, { id });
return this.db.get<FeatureCollection>(`geojson:${id}`);
}

/**
* Remove a geojson object by id
* @param id unique id for the geojson
*/
async remove(id: GeoJSONItem['id']): Promise<void> {
this.logger.debug('Remove', DEBUG_TAG, { id });
await this.db.remove(`geojson:${id}`);
this.metadata_.update((items) => items.filter((item) => item.id !== id));
this.removedMetadataItemId.next(id);
}
}
14 changes: 5 additions & 9 deletions src/app/modules/map/components/map/map.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,14 @@
></app-map-controls>
}

@if (showObserverTrips()) {
<div
#observerTripsContainer
class="observer-trip-backdrop"
style="display: none"
(click)="removeObserverTripDescription()"
>
<!-- TODO: I stedet for dette, få opp detaljside for en tur som modal? -->
@if (showObserverTrips() && metadataName()) {
<div class="observer-trip-backdrop" (click)="removeObserverTripDescription()">
<div class="observer-trip-desc">
<div>
<strong>Obsturbeskrivelse{{ observationTripName ? " " + observationTripName : "" }}:</strong>
<strong>Obsturbeskrivelse{{ metadataName() ? " " + metadataName() : "" }}:</strong>
</div>
<div>{{ observationTripDescription }}</div>
<div>{{ metadataDescription() }}</div>
</div>
</div>
}
Loading