diff --git a/.github/workflows/node-deploy.yml b/.github/workflows/node-deploy.yml index 8627c4dfe..2b511c650 100644 --- a/.github/workflows/node-deploy.yml +++ b/.github/workflows/node-deploy.yml @@ -28,6 +28,7 @@ jobs: run: | npm ci npm run build-dev-ebp + cp ./dist/browser/index.html ./dist/browser/404.html - name: Create CNAME file for custom domain run: echo 'dev.geo.zh.ch' > ./dist/browser/CNAME diff --git a/.readme/are.png b/.readme/are.png new file mode 100644 index 000000000..17230b25a Binary files /dev/null and b/.readme/are.png differ diff --git a/.readme/ebp.png b/.readme/ebp.png new file mode 100644 index 000000000..7e89dfb1a Binary files /dev/null and b/.readme/ebp.png differ diff --git a/Dockerfile b/Dockerfile index 6539c0b9b..cf175f84c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,6 @@ COPY ./.docker/nginx.conf /etc/nginx/nginx.conf COPY --from=build-app /app/dist/browser /usr/share/nginx/html -ENV PORT 8080 -EXPOSE 8080 -CMD sh -c "rm -f /var/log/nginx/* && envsubst '\$PORT' < /etc/nginx/conf.d/configfile.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'" +ENV PORT=8080 +EXPOSE $PORT +CMD ["sh", "-c", "rm -f /var/log/nginx/* && envsubst '\\$PORT' < /etc/nginx/conf.d/configfile.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] diff --git a/README.md b/README.md index 9c67e2282..e83bf7054 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This project was generated with [Angular CLI](https://github.com/angular/angular > 6. [Code documentation](#code-documentation) > 7. [Git conventions](#git-conventions) > 8. [Release management](#release-management) +> 9. [Contributors](#contributors) ## Node version @@ -170,6 +171,7 @@ match. This is because there are times when you _might_ want to deviate from the > 9. [Application Initialization based on share link](#application-initialization-based-on-share-link) > 10. [Adding new NPM packages](#adding-new-npm-packages) > 11. [Feature flags](#feature-flags) +> 12. [Handling of date objects](#handling-of-date-objects) ### The `ActiveMapItem` class @@ -600,6 +602,12 @@ Feature flags can be used to toggle features throughout the application. They wo - The `FeatureFlagsService` and its `getFeatureFlag` method is used to access the feature flags. - For convenience, the `FeatureFlagDirective` can be used to toggle elements based on a feature flag. +### Handling of date objects + +Currently, we are using [dayjs](https://day.js.org/) to handle date objects. In order to have a high degree of abstraction and to be able to easily replace the library (i.e. using native Javascript features like `Intl`), +all date handlings are done via the `TimeService` interface, which is implemented as e.g. the `DayjsService`. Currently, the actual implementation is injected via the `timeServiceFactory`; and as a convenience, this is also +added in `test.ts` so it does not have to be provided for each and every test. + ## Git conventions ### Branching strategy @@ -626,38 +634,22 @@ There are the following branches: ## Release management -This section gives a brief overview of the release process and how to publish it. - -### Azure Devops - -#### 1. Create a changelog file anywhere - -It will be used later within a Teams announcement. +This is still WIP after our move from Azure DevOps to GitHub. -#### 2. Frontend +## Contributors -1. Create a pull request in `gb3-frontend` from `develop` to `main` with the title **_Release_** -2. Click on **Add commit message** and remove any line that does **not** start with `Merged PR XXXXX:` \ - This has the effect that only a few lines remain containing a good summary of the release. -3. Remove the `Merged PR XXXXX:` from the beginning of each line - only the PR titles should remain. Add them to the changelog. -4. Finish creating the PR by clicking on **Create** - no need to add any reviewers. -5. Wait until the required checks succeed and then merge the PR. + -#### 3. All other repositories +The project was developed for the [Amt für Raumentwicklung - Abteilung Geoinformation](https://gis.zh.ch) of the Canton of Zurich. -1. Check if all required features are merged back to `main` -2. Copy all PR titles and add them to the changelog. + -#### 4. Create a release using the release pipeline '_Code Mirroring_' +It has been initially developed by [EBP Schweiz AG](https://ebp.ch) as a closed-source project and has been made open-source in 2024 after the first production release. EBP is still actively contributing and maintaining the project. -1. Click on **Create release** -2. Add the changelog from above to the `Release description` field. -3. Start the release by clicking **Create** +### Individual contributors -#### 5. Publish the changelog +The following people have contributed to this project: -1. Open Teams and go to the GB3 **Allgemein** channel -2. Click on **Einen Beitrag starten** -3. Change the type of article by choosing **Ankündigung** (left of **Veröffentlichen**) -4. Enter the release title as _Überschrift_. E.g. `Release 42` -5. Add the changelog from above as text and slightly format it. Use previous changelogs as styleguide. + + + diff --git a/sonar-project.properties b/sonar-project.properties index 2ee47986e..76bce59ff 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,5 @@ -sonar.projectKey=222262_GB3_frontend -sonar.projectName=222262_GB3_frontend +sonar.projectKey=gisktzh_gb3-web_ui +sonar.organization=gisktzh # root path which is relative to the sonar-project.properties file location. sonar.sources=. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 171655b29..152a82082 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -32,6 +32,8 @@ import {EsriMapLoaderService} from './map/services/esri-services/esri-map-loader import {MapLoaderService} from './map/interfaces/map-loader.service'; import {DevModeBannerComponent} from './shared/components/dev-mode-banner/dev-mode-banner.component'; import {SkipLinkComponent} from './shared/components/skip-link/skip-link.component'; +import {TimeService} from './shared/interfaces/time-service.interface'; +import {timeServiceFactory} from './shared/factories/time-service.factory'; // necessary for the locale 'de-CH' to work // see https://stackoverflow.com/questions/46419026/missing-locale-data-for-the-locale-xxx-with-angular @@ -41,6 +43,7 @@ export const MAP_SERVICE = new InjectionToken('MapService'); export const MAP_LOADER_SERVICE = new InjectionToken('MapLoaderService'); export const NEWS_SERVICE = new InjectionToken('NewsService'); export const GRAV_CMS_SERVICE = new InjectionToken('GravCmsService'); +export const TIME_SERVICE = new InjectionToken('TimeService'); @NgModule({ declarations: [AppComponent], @@ -62,6 +65,7 @@ export const GRAV_CMS_SERVICE = new InjectionToken('GravCmsServi {provide: ErrorHandler, deps: [Router, ErrorHandlerService, EmbeddedErrorHandlerService], useFactory: errorHandlerServiceFactory}, {provide: MAP_SERVICE, useClass: EsriMapService}, {provide: MAP_LOADER_SERVICE, useClass: EsriMapLoaderService}, + {provide: TIME_SERVICE, useFactory: timeServiceFactory}, {provide: NEWS_SERVICE, deps: [KTZHNewsService, KTZHNewsServiceMock, ConfigService], useFactory: newsFactory}, {provide: GRAV_CMS_SERVICE, deps: [GravCmsService, GravCmsServiceMock, ConfigService], useFactory: gravCmsFactory}, {provide: LOCALE_ID, useValue: 'de-CH'}, diff --git a/src/app/data-catalogue/components/data-display/data-display.component.html b/src/app/data-catalogue/components/data-display/data-display.component.html index b162e925b..9793df8b6 100644 --- a/src/app/data-catalogue/components/data-display/data-display.component.html +++ b/src/app/data-catalogue/components/data-display/data-display.component.html @@ -4,7 +4,7 @@ @switch (element.type) { @case ('text') { - + } @case ('url') { @if (element.value) { diff --git a/src/app/data-catalogue/components/data-display/data-display.component.ts b/src/app/data-catalogue/components/data-display/data-display.component.ts index aa95a5a55..730ac179d 100644 --- a/src/app/data-catalogue/components/data-display/data-display.component.ts +++ b/src/app/data-catalogue/components/data-display/data-display.component.ts @@ -1,11 +1,12 @@ import {Component, Input} from '@angular/core'; import {DataDisplayElement} from '../../types/data-display-element.type'; import {NgForOf} from '@angular/common'; +import {TextOrPlaceholderPipe} from '../../../shared/pipes/text-or-placeholder.pipe'; @Component({ selector: 'data-display', standalone: true, - imports: [NgForOf], + imports: [NgForOf, TextOrPlaceholderPipe], templateUrl: './data-display.component.html', styleUrls: ['./data-display.component.scss'], }) diff --git a/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.html b/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.html index d08ed082f..351c00f0b 100644 --- a/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.html +++ b/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.html @@ -8,10 +8,13 @@ @for (attribute of attributes; track attribute.name) { - {{ attribute.name }} - - {{ attribute.type }} - {{ attribute.unit }} + {{ attribute.name | textOrPlaceholder }} + + {{ attribute.type | textOrPlaceholder }} + {{ attribute.unit | textOrPlaceholder }} } diff --git a/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.ts b/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.ts index 98fe40a45..14af52584 100644 --- a/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.ts +++ b/src/app/data-catalogue/components/dataset-detail/dataset-element-table/dataset-element-table.component.ts @@ -2,11 +2,12 @@ import {Component, Input} from '@angular/core'; import {LayerAttributes} from '../../../../shared/interfaces/layer-attributes.interface'; import {NgForOf} from '@angular/common'; import {FormatLineBreaksPipe} from '../../../../shared/pipes/format-line-breaks.pipe'; +import {TextOrPlaceholderPipe} from '../../../../shared/pipes/text-or-placeholder.pipe'; @Component({ selector: 'dataset-element-table', standalone: true, - imports: [NgForOf, FormatLineBreaksPipe], + imports: [NgForOf, FormatLineBreaksPipe, TextOrPlaceholderPipe], templateUrl: './dataset-element-table.component.html', styleUrl: './dataset-element-table.component.scss', }) diff --git a/src/app/data-catalogue/utils/data-extraction.utils.spec.ts b/src/app/data-catalogue/utils/data-extraction.utils.spec.ts index 7ac104400..b0dc99737 100644 --- a/src/app/data-catalogue/utils/data-extraction.utils.spec.ts +++ b/src/app/data-catalogue/utils/data-extraction.utils.spec.ts @@ -19,7 +19,7 @@ describe('DataExtractionUtils', () => { zipCode: 2222, poBox: null, section: null, - url: 'https://www.example.com', + url: {href: 'https://www.example.com'}, }; const result = DataExtractionUtils.extractContactElements(mockContact); @@ -36,7 +36,7 @@ describe('DataExtractionUtils', () => { {title: 'Tel', value: mockContact.phone, type: 'text'}, {title: 'Tel direkt', value: mockContact.phoneDirect, type: 'text'}, {title: 'E-Mail', value: mockContact.email, type: 'url'}, - {title: 'www', value: {href: mockContact.url}, type: 'url'}, + {title: 'www', value: mockContact.url, type: 'url'}, ]; expect(result).toEqual(expected); }); diff --git a/src/app/data-catalogue/utils/data-extraction.utils.ts b/src/app/data-catalogue/utils/data-extraction.utils.ts index bec6699fc..d38f3751b 100644 --- a/src/app/data-catalogue/utils/data-extraction.utils.ts +++ b/src/app/data-catalogue/utils/data-extraction.utils.ts @@ -12,7 +12,7 @@ export class DataExtractionUtils { {title: 'Tel', value: contact.phone, type: 'text'}, {title: 'Tel direkt', value: contact.phoneDirect, type: 'text'}, {title: 'E-Mail', value: contact.email, type: 'url'}, - {title: 'www', value: {href: contact.url}, type: 'url'}, + {title: 'www', value: contact.url, type: 'url'}, ]; } } diff --git a/src/app/map/components/map-controls/coordinate-scale-inputs/coordinate-scale-inputs.component.html b/src/app/map/components/map-controls/coordinate-scale-inputs/coordinate-scale-inputs.component.html index 1bc85d0c9..e935bea6d 100644 --- a/src/app/map/components/map-controls/coordinate-scale-inputs/coordinate-scale-inputs.component.html +++ b/src/app/map/components/map-controls/coordinate-scale-inputs/coordinate-scale-inputs.component.html @@ -4,6 +4,7 @@ more infos: https://stackoverflow.com/questions/50085494/how-to-check-if-a-given-value-is-in-a-union-type-array const allowedDatePickerManipulationUnits = ['years', 'months', 'days'] as const; // TS3.4 syntax -type DatePickerManipulationUnits = Extract; +type DatePickerManipulationUnits = Extract; type DatePickerStartView = 'month' | 'year' | 'multi-year'; @Component({ @@ -47,7 +45,10 @@ export class TimeSliderComponent implements OnInit, OnChanges { public datePickerStartView: DatePickerStartView = 'month'; private datePickerUnit: DatePickerManipulationUnits = 'days'; - constructor(private readonly timeSliderService: TimeSliderService) {} + constructor( + private readonly timeSliderService: TimeSliderService, + @Inject(TIME_SERVICE) private readonly timeService: TimeService, + ) {} public ngOnInit() { this.availableDates = this.timeSliderService.createStops(this.timeSliderConfiguration); @@ -57,7 +58,7 @@ export class TimeSliderComponent implements OnInit, OnChanges { this.timeExtentDisplay = {start: this.initialTimeExtent.start, end: this.initialTimeExtent.end}; this.firstSliderPosition = this.findPositionOfDate(this.timeExtent.start) ?? 0; this.secondSliderPosition = this.timeSliderConfiguration.range ? undefined : this.findPositionOfDate(this.timeExtent.end); - this.hasSimpleCurrentValue = this.isRangeExactlyOneOfSingleTimeUnit(this.timeSliderConfiguration.range); + this.hasSimpleCurrentValue = this.isStringSingleTimeUnitRange(this.timeSliderConfiguration.range); // date picker this.hasDatePicker = this.isRangeContinuousWithinAllowedTimeUnits(this.timeSliderConfiguration); @@ -101,19 +102,19 @@ export class TimeSliderComponent implements OnInit, OnChanges { // correct the thumb that was modified with the calculated time extent if necessary (e.g. enforcing a minimal range) const hasStartTimeBeenCorrected = - TimeExtentUtils.calculateDifferenceBetweenDates(newValidatedTimeExtent.start, newTimeExtent.start) > 0; + this.timeService.calculateDifferenceBetweenDates(newValidatedTimeExtent.start, newTimeExtent.start) > 0; if (hasStartTimeBeenCorrected) { this.firstSliderPosition = this.findPositionOfDate(newValidatedTimeExtent.start) ?? 0; } - const hasEndTimeBeenCorrected = TimeExtentUtils.calculateDifferenceBetweenDates(newValidatedTimeExtent.end, newTimeExtent.end) > 0; + const hasEndTimeBeenCorrected = this.timeService.calculateDifferenceBetweenDates(newValidatedTimeExtent.end, newTimeExtent.end) > 0; if (!this.timeSliderConfiguration.range && hasEndTimeBeenCorrected) { this.secondSliderPosition = this.findPositionOfDate(newValidatedTimeExtent.end) ?? 0; } // overwrite the current time extent and trigger the corresponding event if the new validated time extent is different from the previous one if ( - TimeExtentUtils.calculateDifferenceBetweenDates(this.timeExtent.start, newValidatedTimeExtent.start) > 0 || - TimeExtentUtils.calculateDifferenceBetweenDates(this.timeExtent.end, newValidatedTimeExtent.end) > 0 + this.timeService.calculateDifferenceBetweenDates(this.timeExtent.start, newValidatedTimeExtent.start) > 0 || + this.timeService.calculateDifferenceBetweenDates(this.timeExtent.end, newValidatedTimeExtent.end) > 0 ) { this.timeExtent = newValidatedTimeExtent; this.changeTimeExtentEvent.emit(this.timeExtent); @@ -145,8 +146,12 @@ export class TimeSliderComponent implements OnInit, OnChanges { if (eventDate === null) { return; } + // format the given event date to the configured time format and back to ensure that it is a valid date within the current available dates - const date = dayjs(dayjs(eventDate).format(this.timeSliderConfiguration.dateFormat), this.timeSliderConfiguration.dateFormat).toDate(); + const date = this.timeService.createDateFromString( + this.timeService.getDateAsFormattedString(eventDate, this.timeSliderConfiguration.dateFormat), + this.timeSliderConfiguration.dateFormat, + ); const position = this.findPositionOfDate(date); if (position !== undefined) { if (changedMinimumDate) { @@ -158,23 +163,8 @@ export class TimeSliderComponent implements OnInit, OnChanges { } } - /** - * Returns `true` if the given range is defined and is exactly one of a single time unit (year, month, ...). - * If the optional parameter `allowedTimeUnits` is given then only the units in there are allowed; all other return `false` as well. - * @param range The range (in ISO-8601 time span format) to be evaluated - * - * @example - * `P1Y1M` is a duration of one year AND one month which is more than one time unit; therefore is the result `false` - * `P2Y` is a duration of two years which is more than one of a single time unit; therefore is the result `false` - * `P1D` is a duration of one day which is exactly one of a single time unit; therefore the result is `true` - */ - private isRangeExactlyOneOfSingleTimeUnit(range: string | null | undefined): boolean { - if (range) { - const rangeDuration = dayjs.duration(range); - const unit = TimeExtentUtils.extractUniqueUnitFromDuration(rangeDuration); - return unit !== undefined && TimeExtentUtils.getDurationAsNumber(rangeDuration, unit) === 1; - } - return false; + private isStringSingleTimeUnitRange(range: string | null | undefined): boolean { + return range ? this.timeService.isStringSingleTimeUnitRange(range) : false; } /** @@ -201,7 +191,7 @@ export class TimeSliderComponent implements OnInit, OnChanges { } private extractUniqueDatePickerUnitFromDateFormat(dateFormat: string): DatePickerManipulationUnits | undefined { - const unit = TimeExtentUtils.extractUniqueUnitFromDateFormat(dateFormat); + const unit = this.timeSliderService.extractUniqueUnitFromDateFormat(dateFormat); if (unit !== undefined && allowedDatePickerManipulationUnits.some((allowedUnit) => allowedUnit === unit)) { return unit as DatePickerManipulationUnits; } @@ -209,7 +199,9 @@ export class TimeSliderComponent implements OnInit, OnChanges { } private isLayerSourceContinuous(layerSource: TimeSliderLayerSource, unit: DatePickerManipulationUnits): boolean { - const dateAsAscendingSortedNumbers = layerSource.layers.map((layer) => dayjs(layer.date, unit).get(unit)).sort((a, b) => a - b); + const dateAsAscendingSortedNumbers = layerSource.layers + .map((layer) => this.timeService.createPartialFromString(layer.date, unit)) + .sort((a, b) => a - b); // all date numbers must be part of a continuous and strictly monotonously rising series each with exactly // one step between them: `date[0] = x` => `date[n] = x + n` return !dateAsAscendingSortedNumbers.some((dateAsNumber, index) => dateAsNumber !== dateAsAscendingSortedNumbers[0] + index); @@ -217,7 +209,7 @@ export class TimeSliderComponent implements OnInit, OnChanges { private findPositionOfDate(date: Date): number | undefined { const index = this.availableDates.findIndex( - (availableDate) => TimeExtentUtils.calculateDifferenceBetweenDates(availableDate, date) === 0, + (availableDate) => this.timeService.calculateDifferenceBetweenDates(availableDate, date) === 0, ); return index === -1 ? undefined : index; } diff --git a/src/app/map/interfaces/time-slider.service.ts b/src/app/map/interfaces/time-slider.service.ts deleted file mode 100644 index 00401edca..000000000 --- a/src/app/map/interfaces/time-slider.service.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {Observable} from 'rxjs'; -import {TimeExtent} from './time-extent.interface'; -import {TimeSliderConfiguration} from '../../shared/interfaces/topic.interface'; -import {Gb2WmsActiveMapItem} from '../models/implementations/gb2-wms.model'; - -export interface TimeSliderService { - /** Assigns a time slider widget to the given container based on the active map item */ - assignTimeSliderWidget(activeMapItem: Gb2WmsActiveMapItem, container: HTMLDivElement): void; - - /** Returns an observable which fires an event in case the time extent changes for the active map item with the given ID */ - watchTimeExtent(activeMapItemId: string): Observable; - - /** Creates a new time extent from the given new time extent. The created time extent will be fully validated against - * the limitations and rules of the time slider configuration. */ - createValidTimeExtent(timeSliderConfig: TimeSliderConfiguration, newValue: TimeExtent, oldValue?: TimeExtent): TimeExtent; -} diff --git a/src/app/map/models/implementations/gb2-wms.model.ts b/src/app/map/models/implementations/gb2-wms.model.ts index 01265ece1..48300d079 100644 --- a/src/app/map/models/implementations/gb2-wms.model.ts +++ b/src/app/map/models/implementations/gb2-wms.model.ts @@ -1,6 +1,5 @@ import {FilterConfiguration, Map, MapLayer, SearchConfiguration, TimeSliderConfiguration} from '../../../shared/interfaces/topic.interface'; import {TimeExtent} from '../../interfaces/time-extent.interface'; -import {TimeExtentUtils} from '../../../shared/utils/time-extent.utils'; import {AbstractActiveMapItemSettings, ActiveMapItem} from '../active-map-item.model'; import {AddToMapVisitor} from '../../interfaces/add-to-map.visitor'; @@ -23,7 +22,7 @@ export class Gb2WmsSettings extends AbstractActiveMapItemSettings { this.layers = layer ? [layer] : map.layers; this.timeSliderConfiguration = map.timeSliderConfiguration; if (map.timeSliderConfiguration) { - this.timeSliderExtent = timeExtent ?? TimeExtentUtils.createInitialTimeSliderExtent(map.timeSliderConfiguration); + this.timeSliderExtent = timeExtent ?? map.initialTimeSliderExtent; } this.filterConfigurations = filterConfigurations ?? map.filterConfigurations; this.searchConfigurations = map.searchConfigurations; diff --git a/src/app/map/pipes/date-to-string.pipe.spec.ts b/src/app/map/pipes/date-to-string.pipe.spec.ts index 83b8e3530..2715b801f 100644 --- a/src/app/map/pipes/date-to-string.pipe.spec.ts +++ b/src/app/map/pipes/date-to-string.pipe.spec.ts @@ -1,14 +1,23 @@ import {DateToStringPipe} from './date-to-string.pipe'; +import {TestBed} from '@angular/core/testing'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; describe('DateToStringPipe', () => { - it('create an instance', () => { - const pipe = new DateToStringPipe(); + let pipe: DateToStringPipe; + let timeService: TimeService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + timeService = TestBed.inject(TIME_SERVICE); + pipe = new DateToStringPipe(timeService); + }); + + it('creates an instance', () => { expect(pipe).toBeTruthy(); }); it('formats a defined date', () => { - const pipe = new DateToStringPipe(); - const date = new Date(2023, 1, 3); // monthIndex + 1 === month const dateFormat = 'YYYY-MM-DD'; @@ -19,8 +28,6 @@ describe('DateToStringPipe', () => { }); it('formats an undefined date', () => { - const pipe = new DateToStringPipe(); - const date = undefined; const dateFormat = 'YYYY-MM-DD'; diff --git a/src/app/map/pipes/date-to-string.pipe.ts b/src/app/map/pipes/date-to-string.pipe.ts index 3cbb7e61b..b40439a41 100644 --- a/src/app/map/pipes/date-to-string.pipe.ts +++ b/src/app/map/pipes/date-to-string.pipe.ts @@ -1,12 +1,15 @@ -import {Pipe, PipeTransform} from '@angular/core'; -import dayjs from 'dayjs'; +import {Inject, Pipe, PipeTransform} from '@angular/core'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; @Pipe({ name: 'dateToString', standalone: true, }) export class DateToStringPipe implements PipeTransform { + constructor(@Inject(TIME_SERVICE) private readonly timeService: TimeService) {} + public transform(value: Date | undefined, dateFormat: string): string { - return value ? dayjs(value).format(dateFormat) : ''; + return value ? this.timeService.getDateAsFormattedString(value, dateFormat) : ''; } } diff --git a/src/app/map/pipes/time-extent-to-string.pipe.spec.ts b/src/app/map/pipes/time-extent-to-string.pipe.spec.ts index 572d8076c..24319706c 100644 --- a/src/app/map/pipes/time-extent-to-string.pipe.spec.ts +++ b/src/app/map/pipes/time-extent-to-string.pipe.spec.ts @@ -1,15 +1,23 @@ import {TimeExtentToStringPipe} from './time-extent-to-string.pipe'; import {TimeExtent} from '../interfaces/time-extent.interface'; +import {TestBed} from '@angular/core/testing'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; describe('TimeExtentToStringPipe', () => { - it('create an instance', () => { - const pipe = new TimeExtentToStringPipe(); + let pipe: TimeExtentToStringPipe; + let timeService: TimeService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + timeService = TestBed.inject(TIME_SERVICE); + pipe = new TimeExtentToStringPipe(timeService); + }); + it('creates an instance', () => { expect(pipe).toBeTruthy(); }); it('formats a defined time extent', () => { - const pipe = new TimeExtentToStringPipe(); - const timeExtent: TimeExtent = { start: new Date(2023, 1, 3), end: new Date(2024, 2, 4), @@ -24,8 +32,6 @@ describe('TimeExtentToStringPipe', () => { }); it('formats a defined time extent using simple values', () => { - const pipe = new TimeExtentToStringPipe(); - const timeExtent: TimeExtent = { start: new Date(2023, 1, 3), end: new Date(2024, 2, 4), @@ -40,8 +46,6 @@ describe('TimeExtentToStringPipe', () => { }); it('formats an undefined time extent', () => { - const pipe = new TimeExtentToStringPipe(); - const timeExtent = undefined; const dateFormat = 'YYYY-MM-DD'; const hasSimpleCurrentValue = true; diff --git a/src/app/map/pipes/time-extent-to-string.pipe.ts b/src/app/map/pipes/time-extent-to-string.pipe.ts index f39eb5216..c328ce1e4 100644 --- a/src/app/map/pipes/time-extent-to-string.pipe.ts +++ b/src/app/map/pipes/time-extent-to-string.pipe.ts @@ -1,12 +1,15 @@ -import {Pipe, PipeTransform} from '@angular/core'; +import {Inject, Pipe, PipeTransform} from '@angular/core'; import {TimeExtent} from '../interfaces/time-extent.interface'; -import dayjs from 'dayjs'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; @Pipe({ name: 'timeExtentToString', standalone: true, }) export class TimeExtentToStringPipe implements PipeTransform { + constructor(@Inject(TIME_SERVICE) private readonly timeService: TimeService) {} + public transform(timeExtent: TimeExtent | undefined, dateFormat: string, hasSimpleCurrentValue: boolean): string { if (!timeExtent) { return ''; @@ -17,6 +20,6 @@ export class TimeExtentToStringPipe implements PipeTransform { } private convertDateToString(value: Date, dateFormat: string): string { - return value ? dayjs(value).format(dateFormat) : ''; + return value ? this.timeService.getDateAsFormattedString(value, dateFormat) : ''; } } diff --git a/src/app/map/services/esri-services/esri-map.service.ts b/src/app/map/services/esri-services/esri-map.service.ts index 68f4008ca..534cff0f2 100644 --- a/src/app/map/services/esri-services/esri-map.service.ts +++ b/src/app/map/services/esri-services/esri-map.service.ts @@ -1,9 +1,8 @@ -import {Injectable, OnDestroy} from '@angular/core'; +import {Inject, Injectable, OnDestroy} from '@angular/core'; import esriConfig from '@arcgis/core/config'; import * as geometryEngine from '@arcgis/core/geometry/geometryEngine'; import GraphicsLayer from '@arcgis/core/layers/GraphicsLayer'; import {Store} from '@ngrx/store'; -import dayjs from 'dayjs'; import {BehaviorSubject, first, pairwise, skip, Subscription, tap, withLatestFrom} from 'rxjs'; import {filter, map} from 'rxjs/operators'; import {AuthService} from '../../../auth/auth.service'; @@ -59,12 +58,14 @@ import {TransformationService} from './transformation.service'; import {ExternalWmsActiveMapItem} from '../../models/implementations/external-wms.model'; import {ExternalKmlActiveMapItem} from '../../models/implementations/external-kml.model'; import {ExternalWmsLayer} from '../../../shared/interfaces/external-layer.interface'; -import {ActiveTimeSliderLayersUtils} from '../../utils/active-time-slider-layers.utils'; import {Gb3TopicsService} from '../../../shared/services/apis/gb3/gb3-topics.service'; import {InitialMapExtentService} from '../initial-map-extent.service'; import {MapConstants} from '../../../shared/constants/map.constants'; import {HitTestSelectionUtils} from './utils/hit-test-selection.utils'; import * as intl from '@arcgis/core/intl'; +import {TimeService} from '../../../shared/interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../../app.module'; +import {TimeSliderService} from '../time-slider.service'; import GraphicHit = __esri.GraphicHit; const DEFAULT_POINT_ZOOM_EXTENT_SCALE = 750; @@ -106,6 +107,8 @@ export class EsriMapService implements MapService, OnDestroy { private readonly esriToolService: EsriToolService, private readonly gb3TopicsService: Gb3TopicsService, private readonly initialMapExtentService: InitialMapExtentService, + private readonly timeSliderService: TimeSliderService, + @Inject(TIME_SERVICE) private readonly timeService: TimeService, ) { /** * Because the GetCapabalities response often sends a non-secure http://wms.zh.ch response, Esri Javascript API fails on https @@ -635,8 +638,14 @@ export class EsriMapService implements MapService, OnDestroy { const dateFormat = timeSliderConfiguration.dateFormat; esriLayer.customLayerParameters = esriLayer.customLayerParameters ?? {}; - esriLayer.customLayerParameters[timeSliderParameterSource.startRangeParameter] = dayjs.utc(timeSliderExtent.start).format(dateFormat); - esriLayer.customLayerParameters[timeSliderParameterSource.endRangeParameter] = dayjs.utc(timeSliderExtent.end).format(dateFormat); + esriLayer.customLayerParameters[timeSliderParameterSource.startRangeParameter] = this.timeService.getDateAsUTCString( + timeSliderExtent.start, + dateFormat, + ); + esriLayer.customLayerParameters[timeSliderParameterSource.endRangeParameter] = this.timeService.getDateAsUTCString( + timeSliderExtent.end, + dateFormat, + ); } /** @@ -658,7 +667,7 @@ export class EsriMapService implements MapService, OnDestroy { const timeSliderLayerSource = timeSliderConfig.source as TimeSliderLayerSource; const timeSliderLayerNames = timeSliderLayerSource.layers.map((layer) => layer.layerName); const visibleTimeSliderLayers = mapItem.settings.layers.filter( - (layer) => ActiveTimeSliderLayersUtils.isLayerVisible(layer, mapItem.settings.timeSliderConfiguration, timeSliderExtent) === true, + (layer) => this.timeSliderService.isLayerVisible(layer, mapItem.settings.timeSliderConfiguration, timeSliderExtent) === true, ); // include all layers that are not specified in the time slider config const esriSublayers = esriLayer.sublayers.filter((sublayer) => !timeSliderLayerNames.includes(sublayer.name)); diff --git a/src/app/map/services/favourites.service.spec.ts b/src/app/map/services/favourites.service.spec.ts index 6df9215f7..2c02bda4a 100644 --- a/src/app/map/services/favourites.service.spec.ts +++ b/src/app/map/services/favourites.service.spec.ts @@ -20,13 +20,17 @@ import {DrawingActiveMapItem} from '../models/implementations/drawing.model'; import {DrawingLayerPrefix, UserDrawingLayer} from '../../shared/enums/drawing-layer.enum'; import {SymbolizationToGb3ConverterUtils} from '../../shared/utils/symbolization-to-gb3-converter.utils'; import {Map} from '../../shared/interfaces/topic.interface'; -import {TimeExtentUtils} from '../../shared/utils/time-extent.utils'; import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeSliderService} from './time-slider.service'; describe('FavouritesService', () => { let service: FavouritesService; let store: MockStore; let gb3FavouritesService: Gb3FavouritesService; + let timeService: TimeService; + let timeSliderService: TimeSliderService; beforeEach(() => { TestBed.configureTestingModule({ @@ -34,6 +38,8 @@ describe('FavouritesService', () => { providers: [provideMockStore({}), provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()], }); store = TestBed.inject(MockStore); + timeService = TestBed.inject(TIME_SERVICE); + timeSliderService = TestBed.inject(TimeSliderService); store.overrideSelector(selectActiveMapItemConfigurations, []); store.overrideSelector(selectMaps, []); store.overrideSelector(selectFavouriteBaseConfig, {center: {x: 0, y: 0}, scale: 0, basemap: ''}); @@ -79,246 +85,257 @@ describe('FavouritesService', () => { }); describe('getActiveMapItemsForFavourite', () => { - const availableMaps: Map[] = [ - { - id: 'FaBoFFFZH', - uuid: '27fd3dc4-a4d8-450c-837f-70dd2f5cd5fe', - printTitle: 'Fruchtfolgeflächen (FFF)', - gb2Url: null, - icon: 'https://maps.zh.ch/images/custom/themekl-fabofffzh.gif', - wmsUrl: 'https://maps.zh.ch/wms/FaBoFFFZH', - minScale: 2500, - organisation: 'ALN Bodenschutz', - notice: null, - title: 'Fruchtfolgeflächen (FFF)', - keywords: ['Fruchtfolgeflächen', '(FFF)', 'bvv', 'boden', 'TBAK2', 'fsla', 'fabo', 'vp', 'fap'], - opacity: 1, - layers: [ - { - id: 157886, - layer: 'fff', - title: 'Fruchtfolgeflächen', - queryable: true, - uuid: '0aa893ad-c264-ce46-bf1f-6fa785998b8c', - groupTitle: 'Fruchtfolgeflächen', - minScale: 1, - maxScale: 50000, - wmsSort: 2, - tocSort: 200, - visible: true, - isHidden: false, - }, - { - id: 157885, - layer: 'perimeter-fff', - title: 'Fruchtfolgeflächen', - queryable: true, - uuid: '0aa893ad-c264-ce46-bf1f-6fa785998b8c', - groupTitle: 'Fruchtfolgeflächen', - minScale: 50000, - maxScale: 500000, - wmsSort: 1, - tocSort: 100, - visible: true, - isHidden: false, - }, - ], - }, - { - id: 'AVfarbigZH', - uuid: '26d7c027-38f2-42cb-a17a-99f17a2e383e', - printTitle: 'Amtliche Vermessung in Farbe', - gb2Url: null, - icon: 'https://maps.zh.ch/images/custom/themekl-avfarbigzh.gif', - wmsUrl: 'https://maps.zh.ch/wms/AVfarbigZH', - minScale: 100, - organisation: 'ARE Geoinformation', - notice: null, - title: 'Amtliche Vermessung in Farbe', - keywords: ['Amtliche', 'Vermessung', 'in', 'Farbe', 'pk', 'Amtlichen Vermessung', 'AV'], - opacity: 1, - layers: [ - { - id: 151493, - layer: 'liegensch-einzelobj', - title: 'Einzelobjekte (Flächen) innerhalb Liegenschaften', - queryable: true, - uuid: null, - groupTitle: null, - minScale: 99, - maxScale: 2500, - wmsSort: 49, - tocSort: 4900, - visible: true, - isHidden: false, - }, - { - id: 151492, - layer: 'TBBP', - title: 'Hoheitsgrenzpunkte', - queryable: true, - uuid: '1466f09e-702d-7b38-053c-7b85f8e82549', - groupTitle: 'Hoheitsgrenzen', - minScale: 99, - maxScale: 1000, - wmsSort: 47, - tocSort: 9099, - visible: true, - isHidden: false, - }, - { - id: 151491, - layer: 'av-fixpunkte-nummern', - title: 'Fixpunkte Nummern', - queryable: false, - uuid: '75fe4385-de51-3588-40e2-be8575166f2a', - groupTitle: 'Fixpunkte', - minScale: 99, - maxScale: 1000, - wmsSort: 46, - tocSort: 9089, - visible: true, - isHidden: false, - }, - ], - searchConfigurations: [ - { - index: 'gvz', - title: 'GVZ-Nr.', - }, - ], - }, - { - id: 'StatGebAlterZH', - uuid: '246fe226-ead7-4f91-b735-d294994913e0', - printTitle: 'Gebäudealter', - gb2Url: null, - icon: 'https://maps.zh.ch/images/custom/themekl-statgebalterzh.gif', - wmsUrl: 'https://maps.zh.ch/wms/StatGebAlterZH', - minScale: null, - organisation: 'Statistisches Amt', - notice: null, - title: 'Gebäudealter', - keywords: ['Gebäudealter', 'stat', 'obs', 'fap', 'denkk', 'fsla'], - opacity: 1, - layers: [ - { - id: 160331, - layer: 'geb-alter_2', - title: 'Gebäude mit Baujahr x und älter', - queryable: false, - uuid: null, - groupTitle: 'Gebäudealter', - minScale: 100001, - maxScale: 15000001, - wmsSort: 11, - tocSort: 1100, - visible: true, - isHidden: false, - }, - { - id: 160330, - layer: 'geb-alter_grau', - title: 'Baujahr', - queryable: false, - uuid: null, - groupTitle: 'Gebäudealter - Polygone', - minScale: 1, - maxScale: 100000, - wmsSort: 10, - tocSort: 1000, - visible: false, - isHidden: false, - }, - { - id: 160329, - layer: 'geb-alter_wohnen', - title: 'Baujahr', - queryable: true, - uuid: null, - groupTitle: 'Gebäudealter - Polygone', - minScale: 1, - maxScale: 100000, - wmsSort: 9, - tocSort: 900, - visible: true, - isHidden: false, - }, - ], - timeSliderConfiguration: { - name: 'Aktueller Gebäudebestand nach Baujahr', - alwaysMaxRange: false, - dateFormat: 'YYYY', - description: 'Gebäude bis 2020', - maximumDate: '2020', - minimumDate: '1000', - minimalRange: 'P1Y', - sourceType: 'parameter', - source: { - startRangeParameter: 'FILTER_VON', - endRangeParameter: 'FILTER_BIS', - layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], - }, - }, - filterConfigurations: [ - { - name: 'Anzeigeoptionen nach Hauptnutzung', - parameter: 'FILTER_GEBART', - filterValues: [ - { - isActive: true, - values: ['Gebäude Wohnen'], - name: 'Wohnen', - }, - { - isActive: false, - values: ['Gebäude Wohnen'], - name: 'Gewerbe und Verwaltung', - }, - { - isActive: false, - values: ['Gebäude Wohnen'], - name: 'Andere', - }, - ], - }, - ], - }, - { - id: 'Lidar2021BefliegungZH', - uuid: '1dac9be1-1412-45dd-a1dd-c151c737272b', - printTitle: 'LiDAR-Befliegung 2021 ZH', - gb2Url: null, - icon: 'https://maps.zh.ch/images/custom/themekl-lidar2021befliegungzh.gif', - wmsUrl: 'https://maps.zh.ch/wms/Lidar2021BefliegungZH', - minScale: null, - organisation: 'ARE Geoinformation', - notice: - 'Die kantonale LiDAR-Befliegungen sind die Basis für die Berechnung der Höhemodelle und decken den ganzen Kanton ab. Diese Karte zeigt die abgedeckte Fläche jeder aufgenommenen Überfliegung, die während dem Projekt stattgefunden hat. Die Überlappung der Flächen wird benötigt, um die einzelnen Streifen zusammenführen zu können und eine höhere Genauigkeit zu erhalten.', - title: 'LiDAR-Befliegung 2021 ZH', - keywords: ['LiDAR-Befliegung', '2021', 'ZH'], - opacity: 1, - layers: [ - { - id: 159533, - layer: 'lidarbefliegung', - title: 'LiDAR-Befliegung', - queryable: true, - uuid: '10b88da3-3715-44f5-9480-eb754955a892', - groupTitle: 'Inventar', - minScale: 1, - maxScale: 1000000, - wmsSort: 0, - tocSort: 0, - visible: true, - isHidden: false, - }, - ], - }, - ]; + let availableMaps: Map[]; beforeEach(() => { // eslint-disable-next-line @typescript-eslint/dot-notation + availableMaps = [ + { + id: 'FaBoFFFZH', + uuid: '27fd3dc4-a4d8-450c-837f-70dd2f5cd5fe', + printTitle: 'Fruchtfolgeflächen (FFF)', + gb2Url: null, + icon: 'https://maps.zh.ch/images/custom/themekl-fabofffzh.gif', + wmsUrl: 'https://maps.zh.ch/wms/FaBoFFFZH', + minScale: 2500, + organisation: 'ALN Bodenschutz', + notice: null, + title: 'Fruchtfolgeflächen (FFF)', + keywords: ['Fruchtfolgeflächen', '(FFF)', 'bvv', 'boden', 'TBAK2', 'fsla', 'fabo', 'vp', 'fap'], + opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, + layers: [ + { + id: 157886, + layer: 'fff', + title: 'Fruchtfolgeflächen', + queryable: true, + uuid: '0aa893ad-c264-ce46-bf1f-6fa785998b8c', + groupTitle: 'Fruchtfolgeflächen', + minScale: 1, + maxScale: 50000, + wmsSort: 2, + tocSort: 200, + visible: true, + isHidden: false, + }, + { + id: 157885, + layer: 'perimeter-fff', + title: 'Fruchtfolgeflächen', + queryable: true, + uuid: '0aa893ad-c264-ce46-bf1f-6fa785998b8c', + groupTitle: 'Fruchtfolgeflächen', + minScale: 50000, + maxScale: 500000, + wmsSort: 1, + tocSort: 100, + visible: true, + isHidden: false, + }, + ], + }, + { + id: 'AVfarbigZH', + uuid: '26d7c027-38f2-42cb-a17a-99f17a2e383e', + printTitle: 'Amtliche Vermessung in Farbe', + gb2Url: null, + icon: 'https://maps.zh.ch/images/custom/themekl-avfarbigzh.gif', + wmsUrl: 'https://maps.zh.ch/wms/AVfarbigZH', + minScale: 100, + organisation: 'ARE Geoinformation', + notice: null, + title: 'Amtliche Vermessung in Farbe', + keywords: ['Amtliche', 'Vermessung', 'in', 'Farbe', 'pk', 'Amtlichen Vermessung', 'AV'], + opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, + layers: [ + { + id: 151493, + layer: 'liegensch-einzelobj', + title: 'Einzelobjekte (Flächen) innerhalb Liegenschaften', + queryable: true, + uuid: null, + groupTitle: null, + minScale: 99, + maxScale: 2500, + wmsSort: 49, + tocSort: 4900, + visible: true, + isHidden: false, + }, + { + id: 151492, + layer: 'TBBP', + title: 'Hoheitsgrenzpunkte', + queryable: true, + uuid: '1466f09e-702d-7b38-053c-7b85f8e82549', + groupTitle: 'Hoheitsgrenzen', + minScale: 99, + maxScale: 1000, + wmsSort: 47, + tocSort: 9099, + visible: true, + isHidden: false, + }, + { + id: 151491, + layer: 'av-fixpunkte-nummern', + title: 'Fixpunkte Nummern', + queryable: false, + uuid: '75fe4385-de51-3588-40e2-be8575166f2a', + groupTitle: 'Fixpunkte', + minScale: 99, + maxScale: 1000, + wmsSort: 46, + tocSort: 9089, + visible: true, + isHidden: false, + }, + ], + searchConfigurations: [ + { + index: 'gvz', + title: 'GVZ-Nr.', + }, + ], + }, + { + id: 'StatGebAlterZH', + uuid: '246fe226-ead7-4f91-b735-d294994913e0', + printTitle: 'Gebäudealter', + gb2Url: null, + icon: 'https://maps.zh.ch/images/custom/themekl-statgebalterzh.gif', + wmsUrl: 'https://maps.zh.ch/wms/StatGebAlterZH', + minScale: null, + organisation: 'Statistisches Amt', + notice: null, + title: 'Gebäudealter', + keywords: ['Gebäudealter', 'stat', 'obs', 'fap', 'denkk', 'fsla'], + opacity: 1, + layers: [ + { + id: 160331, + layer: 'geb-alter_2', + title: 'Gebäude mit Baujahr x und älter', + queryable: false, + uuid: null, + groupTitle: 'Gebäudealter', + minScale: 100001, + maxScale: 15000001, + wmsSort: 11, + tocSort: 1100, + visible: true, + isHidden: false, + }, + { + id: 160330, + layer: 'geb-alter_grau', + title: 'Baujahr', + queryable: false, + uuid: null, + groupTitle: 'Gebäudealter - Polygone', + minScale: 1, + maxScale: 100000, + wmsSort: 10, + tocSort: 1000, + visible: false, + isHidden: false, + }, + { + id: 160329, + layer: 'geb-alter_wohnen', + title: 'Baujahr', + queryable: true, + uuid: null, + groupTitle: 'Gebäudealter - Polygone', + minScale: 1, + maxScale: 100000, + wmsSort: 9, + tocSort: 900, + visible: true, + isHidden: false, + }, + ], + timeSliderConfiguration: { + name: 'Aktueller Gebäudebestand nach Baujahr', + alwaysMaxRange: false, + dateFormat: 'YYYY', + description: 'Gebäude bis 2020', + maximumDate: '2020', + minimumDate: '1000', + minimalRange: 'P1Y', + sourceType: 'parameter', + source: { + startRangeParameter: 'FILTER_VON', + endRangeParameter: 'FILTER_BIS', + layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], + }, + }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + }, + filterConfigurations: [ + { + name: 'Anzeigeoptionen nach Hauptnutzung', + parameter: 'FILTER_GEBART', + filterValues: [ + { + isActive: true, + values: ['Gebäude Wohnen'], + name: 'Wohnen', + }, + { + isActive: false, + values: ['Gebäude Wohnen'], + name: 'Gewerbe und Verwaltung', + }, + { + isActive: false, + values: ['Gebäude Wohnen'], + name: 'Andere', + }, + ], + }, + ], + }, + { + id: 'Lidar2021BefliegungZH', + uuid: '1dac9be1-1412-45dd-a1dd-c151c737272b', + printTitle: 'LiDAR-Befliegung 2021 ZH', + gb2Url: null, + icon: 'https://maps.zh.ch/images/custom/themekl-lidar2021befliegungzh.gif', + wmsUrl: 'https://maps.zh.ch/wms/Lidar2021BefliegungZH', + minScale: null, + organisation: 'ARE Geoinformation', + notice: + 'Die kantonale LiDAR-Befliegungen sind die Basis für die Berechnung der Höhemodelle und decken den ganzen Kanton ab. Diese Karte zeigt die abgedeckte Fläche jeder aufgenommenen Überfliegung, die während dem Projekt stattgefunden hat. Die Überlappung der Flächen wird benötigt, um die einzelnen Streifen zusammenführen zu können und eine höhere Genauigkeit zu erhalten.', + title: 'LiDAR-Befliegung 2021 ZH', + keywords: ['LiDAR-Befliegung', '2021', 'ZH'], + opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, + layers: [ + { + id: 159533, + layer: 'lidarbefliegung', + title: 'LiDAR-Befliegung', + queryable: true, + uuid: '10b88da3-3715-44f5-9480-eb754955a892', + groupTitle: 'Inventar', + minScale: 1, + maxScale: 1000000, + wmsSort: 0, + tocSort: 0, + visible: true, + isHidden: false, + }, + ], + }, + ]; service['availableMaps'] = availableMaps; }); @@ -384,8 +401,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, { @@ -529,8 +546,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -647,8 +664,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -708,8 +725,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -798,8 +815,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -857,8 +874,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -871,6 +888,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if a new filterConfiguration has been added', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'StatGebAlterZH', @@ -944,6 +962,10 @@ describe('FavouritesService', () => { layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', @@ -1035,8 +1057,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -1049,6 +1071,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if a new filter has been added', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'StatGebAlterZH', @@ -1122,6 +1145,10 @@ describe('FavouritesService', () => { layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', @@ -1193,8 +1220,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -1207,6 +1234,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if the timeSliderConfiguration for a parameter configuration is invalid (start < minimumDate)', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'StatGebAlterZH', @@ -1280,6 +1308,10 @@ describe('FavouritesService', () => { layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', @@ -1350,8 +1382,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('0999-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('0999-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), }, }, ]; @@ -1362,6 +1394,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if the timeSliderConfiguration for a parameter configuration is invalid (range to small)', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'StatGebAlterZH', @@ -1435,6 +1468,10 @@ describe('FavouritesService', () => { layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', @@ -1505,8 +1542,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1450-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('1455-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1450-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('1455-01-01T00:00:00.000Z'), }, }, ]; @@ -1517,6 +1554,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if the timeSliderConfiguration for a parameter configuration is invalid (start and end date mixed up)', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'StatGebAlterZH', @@ -1590,6 +1628,10 @@ describe('FavouritesService', () => { layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', @@ -1660,8 +1702,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1750-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('1455-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1750-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('1455-01-01T00:00:00.000Z'), }, }, ]; @@ -1672,6 +1714,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if the timeSliderConfiguration for a parameter configuration is invalid (not max range if Flag is set)', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'StatGebAlterZH', @@ -1745,6 +1788,10 @@ describe('FavouritesService', () => { layerIdentifiers: ['geb-alter_wohnen', 'geb-alter_grau', 'geb-alter_2'], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('1000-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2020-01-01T00:00:00.000Z'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', @@ -1815,8 +1862,8 @@ describe('FavouritesService', () => { }, ], timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1250-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2000-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('1250-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2000-01-01T00:00:00.000Z'), }, }, ]; @@ -1827,6 +1874,7 @@ describe('FavouritesService', () => { }); it('throws a FavouriteIsInvalidError if the timeSliderConfiguration for a layer configuration is invalid', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'OrthoFCIRZH', @@ -2017,6 +2065,10 @@ describe('FavouritesService', () => { ], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('2016-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2017-01-01T00:00:00.000Z'), + }, filterConfigurations: undefined, }, ]; @@ -2081,8 +2133,8 @@ describe('FavouritesService', () => { isSingleLayer: false, attributeFilters: undefined, timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('2016-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2017-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('2016-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2017-01-01T00:00:00.000Z'), }, }, ]; @@ -2093,6 +2145,7 @@ describe('FavouritesService', () => { }); it('returns the initalTimsliderExtent if the timeExtent is invalid but ignoreErrors is set to true', () => { + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'] = [ { id: 'OrthoFCIRZH', @@ -2283,6 +2336,10 @@ describe('FavouritesService', () => { ], }, }, + initialTimeSliderExtent: { + start: timeService.createUTCDateFromString('2014-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2021-01-01T00:00:00.000Z'), + }, filterConfigurations: undefined, }, ]; @@ -2347,21 +2404,24 @@ describe('FavouritesService', () => { isSingleLayer: false, attributeFilters: undefined, timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('2016-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2017-01-01T00:00:00.000Z'), + start: timeService.createUTCDateFromString('2016-01-01T00:00:00.000Z'), + end: timeService.createUTCDateFromString('2017-01-01T00:00:00.000Z'), }, }, ]; const result = service.getActiveMapItemsForFavourite(activeMapItemConfigurations, true); - const initialTimeExtent = TimeExtentUtils.createInitialTimeSliderExtent(service['availableMaps'][0].timeSliderConfiguration!); + // eslint-disable-next-line @typescript-eslint/dot-notation + const initialTimeExtent = timeSliderService.createInitialTimeSliderExtent(service['availableMaps'][0].timeSliderConfiguration!); const activeMapItems: ActiveMapItem[] = [ ActiveMapItemFactory.createGb2WmsMapItem( + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'][0], undefined, true, 1, initialTimeExtent, + // eslint-disable-next-line @typescript-eslint/dot-notation service['availableMaps'][0].filterConfigurations, ), ]; diff --git a/src/app/map/services/favourites.service.ts b/src/app/map/services/favourites.service.ts index ce11cf47e..120d226b9 100644 --- a/src/app/map/services/favourites.service.ts +++ b/src/app/map/services/favourites.service.ts @@ -1,4 +1,4 @@ -import {Injectable, OnDestroy} from '@angular/core'; +import {Inject, Injectable, OnDestroy} from '@angular/core'; import {Store} from '@ngrx/store'; import {Gb3FavouritesService} from '../../shared/services/apis/gb3/gb3-favourites.service'; import {Observable, Subscription, switchMap, tap, withLatestFrom} from 'rxjs'; @@ -27,8 +27,9 @@ import {SymbolizationToGb3ConverterUtils} from '../../shared/utils/symbolization import {DrawingActiveMapItem} from '../models/implementations/drawing.model'; import {Gb3StyledInternalDrawingRepresentation} from '../../shared/interfaces/internal-drawing-representation.interface'; import {TimeExtent} from '../interfaces/time-extent.interface'; -import {TimeExtentUtils} from '../../shared/utils/time-extent.utils'; import {TimeSliderService} from './time-slider.service'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../app.module'; @Injectable({ providedIn: 'root', @@ -46,6 +47,7 @@ export class FavouritesService implements OnDestroy { private readonly store: Store, private readonly gb3FavouritesService: Gb3FavouritesService, private readonly timeSliderService: TimeSliderService, + @Inject(TIME_SERVICE) private readonly timeService: TimeService, ) { this.initSubscriptions(); } @@ -252,7 +254,7 @@ export class FavouritesService implements OnDestroy { const isValid = this.validateTimeSlider(timeSliderConfiguration, timeExtent); if (!isValid) { if (ignoreErrors) { - return TimeExtentUtils.createInitialTimeSliderExtent(timeSliderConfiguration); + return this.timeSliderService.createInitialTimeSliderExtent(timeSliderConfiguration); } else { throw new FavouriteIsInvalid(`Die Konfiguration für den Zeitschieberegler der Karte '${title}' ist ungültig.`); } @@ -351,7 +353,9 @@ export class FavouritesService implements OnDestroy { return isTimeExtentValid; case 'layer': { const selectedYearExists = (timeSliderConfiguration.source as TimeSliderLayerSource).layers.some( - (layer) => TimeExtentUtils.parseUTCDate(layer.date, timeSliderConfiguration.dateFormat).getTime() === timeExtent.start.getTime(), + (layer) => + this.timeService.createUTCDateFromString(layer.date, timeSliderConfiguration.dateFormat).getTime() === + timeExtent.start.getTime(), ); return selectedYearExists && isTimeExtentValid; } diff --git a/src/app/map/services/time-slider.service.spec.ts b/src/app/map/services/time-slider.service.spec.ts index aa2a9997c..d160bc216 100644 --- a/src/app/map/services/time-slider.service.spec.ts +++ b/src/app/map/services/time-slider.service.spec.ts @@ -1,16 +1,18 @@ import {TestBed} from '@angular/core/testing'; - import {TimeSliderService} from './time-slider.service'; -import dayjs from 'dayjs'; -import {TimeSliderConfiguration, TimeSliderParameterSource} from '../../shared/interfaces/topic.interface'; +import {MapLayer, TimeSliderConfiguration, TimeSliderLayerSource, TimeSliderParameterSource} from '../../shared/interfaces/topic.interface'; import {TimeExtent} from '../interfaces/time-extent.interface'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; describe('TimeSliderService', () => { let service: TimeSliderService; + let timeService: TimeService; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(TimeSliderService); + timeService = TestBed.inject(TIME_SERVICE); }); it('should be created', () => { @@ -20,233 +22,239 @@ describe('TimeSliderService', () => { describe('createValidTimeExtent', () => { describe('using "alwaysMaxRange"', () => { const dateFormat = 'YYYY-MM'; - const minimumDate = dayjs('2000-01', dateFormat); - const maximumDate = dayjs('2001-03', dateFormat); const alwaysMaxRange = true; const range = undefined; const minimalRange = undefined; + let minimumDate: string; + let maximumDate: string; + let timeSliderConfig: TimeSliderConfiguration; - const timeSliderConfig: TimeSliderConfiguration = { - name: 'mockTimeSlider', - dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), - alwaysMaxRange: alwaysMaxRange, - range: range, - minimalRange: minimalRange, - sourceType: 'parameter', - source: { - startRangeParameter: '', - endRangeParameter: '', - layerIdentifiers: [], - }, - }; + beforeEach(() => { + minimumDate = timeService.getDateAsFormattedString(timeService.createDateFromString('2000-01', dateFormat), dateFormat); + maximumDate = timeService.getDateAsFormattedString(timeService.createDateFromString('2001-03', dateFormat), dateFormat); + timeSliderConfig = { + name: 'mockTimeSlider', + dateFormat: dateFormat, + minimumDate, + maximumDate, + alwaysMaxRange: alwaysMaxRange, + range: range, + minimalRange: minimalRange, + sourceType: 'parameter', + source: { + startRangeParameter: '', + endRangeParameter: '', + layerIdentifiers: [], + }, + }; + }); it('should create always the same time extent using min-/max values', () => { const newValue: TimeExtent = { - start: dayjs('2000-02', dateFormat).toDate(), - end: dayjs('2000-03', dateFormat).toDate(), + start: timeService.createDateFromString('2000-02', dateFormat), + end: timeService.createDateFromString('2000-03', dateFormat), }; const calculatedTimeExtent = service.createValidTimeExtent( timeSliderConfig, newValue, true, - minimumDate.toDate(), - maximumDate.toDate(), + timeService.createDateFromString(minimumDate), + timeService.createDateFromString(maximumDate), + ); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString(minimumDate))).toBe( + 0, + ); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString(maximumDate))).toBe( + 0, ); - expect(dayjs(calculatedTimeExtent.start).diff(minimumDate)).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(maximumDate)).toBe(0); }); }); + describe('using "range"', () => { const dateFormat = 'YYYY-MM'; - const minimumDate = dayjs('2000-01', dateFormat); - const maximumDate = dayjs('2001-03', dateFormat); const alwaysMaxRange = false; const range = 'P1M'; const minimalRange = undefined; + let minimumDate: Date; + let maximumDate: Date; + let minimumDateString: string; + let maximumDateString: string; + let timeSliderConfig: TimeSliderConfiguration; - const timeSliderConfig: TimeSliderConfiguration = { - name: 'mockTimeSlider', - dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), - alwaysMaxRange: alwaysMaxRange, - range: range, - minimalRange: minimalRange, - sourceType: 'parameter', - source: { - startRangeParameter: '', - endRangeParameter: '', - layerIdentifiers: [], - }, - }; + beforeEach(() => { + minimumDate = timeService.createDateFromString('2000-01', dateFormat); + maximumDate = timeService.createDateFromString('2001-03', dateFormat); + minimumDateString = timeService.getDateAsFormattedString(minimumDate, dateFormat); + maximumDateString = timeService.getDateAsFormattedString(maximumDate, dateFormat); + timeSliderConfig = { + name: 'mockTimeSlider', + dateFormat: dateFormat, + minimumDate: minimumDateString, + maximumDate: maximumDateString, + alwaysMaxRange: alwaysMaxRange, + range: range, + minimalRange: minimalRange, + sourceType: 'parameter', + source: { + startRangeParameter: '', + endRangeParameter: '', + layerIdentifiers: [], + }, + }; + }); it('should not create a new time extent if it is already valid', () => { const newValue: TimeExtent = { - start: dayjs('2000-02', dateFormat).toDate(), - end: dayjs('2000-03', dateFormat).toDate(), + start: timeService.createDateFromString('2000-02', dateFormat), + end: timeService.createDateFromString('2000-03', dateFormat), }; const calculatedTimeExtent = service.createValidTimeExtent( timeSliderConfig, newValue, true, - minimumDate.toDate(), - maximumDate.toDate(), + timeService.createDateFromString(minimumDateString), + timeService.createDateFromString(maximumDateString), ); - expect(dayjs(calculatedTimeExtent.start).diff(newValue.start)).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(newValue.end)).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, newValue.start)).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, newValue.end)).toBe(0); }); it('should create a new end date if it is valid', () => { - const newValue: TimeExtent = {start: maximumDate.toDate(), end: minimumDate.toDate()}; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2001-03', dateFormat))).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2001-04', dateFormat))).toBe(0); + const newValue: TimeExtent = {start: maximumDate, end: minimumDate}; + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2001-03', dateFormat)), + ).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2001-04', dateFormat)), + ).toBe(0); }); it('should create a new start date if it is smaller than the minimum date', () => { - const newValue: TimeExtent = {start: minimumDate.subtract(dayjs.duration('P1M')).toDate(), end: minimumDate.toDate()}; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2000-01', dateFormat))).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2000-02', dateFormat))).toBe(0); + const newValue: TimeExtent = {start: timeService.subtractRangeFromDate(minimumDate, 'P1M'), end: minimumDate}; + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2000-01', dateFormat)), + ).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2000-02', dateFormat)), + ).toBe(0); }); it('should create a new start date if it is bigger than the maximum date', () => { - const newValue: TimeExtent = {start: maximumDate.add(dayjs.duration('P1M')).toDate(), end: minimumDate.toDate()}; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2001-03', dateFormat))).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2001-04', dateFormat))).toBe(0); + const newValue: TimeExtent = {start: timeService.addRangeToDate(maximumDate, 'P1M'), end: minimumDate}; + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2001-03', dateFormat)), + ).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2001-04', dateFormat)), + ).toBe(0); }); }); + describe('using "minimalRange"', () => { const dateFormat = 'YYYY-MM'; - const minimumDate = dayjs('2000-01', dateFormat); - const maximumDate = dayjs('2001-03', dateFormat); const alwaysMaxRange = false; const range = undefined; const minimalRange = 'P2M'; + let timeSliderConfig: TimeSliderConfiguration; + let minimumDate: Date; + let maximumDate: Date; + let minimumDateString: string; + let maximumDateString: string; - const timeSliderConfig: TimeSliderConfiguration = { - name: 'mockTimeSlider', - dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), - alwaysMaxRange: alwaysMaxRange, - range: range, - minimalRange: minimalRange, - sourceType: 'parameter', - source: { - startRangeParameter: '', - endRangeParameter: '', - layerIdentifiers: [], - }, - }; + beforeEach(() => { + minimumDate = timeService.createDateFromString('2000-01', dateFormat); + maximumDate = timeService.createDateFromString('2001-03', dateFormat); + maximumDateString = timeService.getDateAsFormattedString(maximumDate, dateFormat); + timeSliderConfig = { + name: 'mockTimeSlider', + dateFormat: dateFormat, + minimumDate: minimumDateString, + maximumDate: maximumDateString, + alwaysMaxRange: alwaysMaxRange, + range: range, + minimalRange: minimalRange, + sourceType: 'parameter', + source: { + startRangeParameter: '', + endRangeParameter: '', + layerIdentifiers: [], + }, + }; + }); it('should not create a new time extent if it is already valid', () => { const newValue: TimeExtent = { - start: dayjs('2000-02', dateFormat).toDate(), - end: dayjs('2000-05', dateFormat).toDate(), + start: timeService.createDateFromString('2000-02', dateFormat), + end: timeService.createDateFromString('2000-05', dateFormat), }; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(newValue.start)).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(newValue.end)).toBe(0); + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, newValue.start)).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, newValue.end)).toBe(0); }); it('should create a new start/end date if it is over/under the limits', () => { const newValue: TimeExtent = { - start: dayjs('1999-12', dateFormat).toDate(), - end: dayjs('2001-04', dateFormat).toDate(), + start: timeService.createDateFromString('1999-12', dateFormat), + end: timeService.createDateFromString('2001-04', dateFormat), }; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(minimumDate)).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(maximumDate)).toBe(0); + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, minimumDate)).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, maximumDate)).toBe(0); }); it('should adjust the start date if the new start date is too close to the original start date', () => { const newValue: TimeExtent = { - start: dayjs('2000-03', dateFormat).toDate(), - end: dayjs('2000-04', dateFormat).toDate(), + start: timeService.createDateFromString('2000-03', dateFormat), + end: timeService.createDateFromString('2000-04', dateFormat), }; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2000-02', dateFormat))).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2000-04', dateFormat))).toBe(0); + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2000-02', dateFormat)), + ).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2000-04', dateFormat)), + ).toBe(0); }); it('should adjust the end date if the new end date is too close to the original end date', () => { const newValue: TimeExtent = { - start: dayjs('2000-02', dateFormat).toDate(), - end: dayjs('2000-03', dateFormat).toDate(), + start: timeService.createDateFromString('2000-02', dateFormat), + end: timeService.createDateFromString('2000-03', dateFormat), }; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - false, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2000-02', dateFormat))).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2000-04', dateFormat))).toBe(0); + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, false, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2000-02', dateFormat)), + ).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2000-04', dateFormat)), + ).toBe(0); }); it('should create a new start date if if is too close to the end date and the end date is the maximum possible date', () => { const newValue: TimeExtent = { - start: dayjs('2001-02', dateFormat).toDate(), - end: dayjs('2001-03', dateFormat).toDate(), + start: timeService.createDateFromString('2001-02', dateFormat), + end: timeService.createDateFromString('2001-03', dateFormat), }; - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2001-01', dateFormat))).toBe(0); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2001-03', dateFormat))).toBe(0); + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2001-01', dateFormat)), + ).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2001-03', dateFormat)), + ).toBe(0); }); }); it('should use the correct range in case of years', () => { const dateFormat = 'YYYY'; - const minimumDate = dayjs('2000', dateFormat); - const maximumDate = dayjs('2002', dateFormat); + const minimumDate = timeService.createDateFromString('2000-01', dateFormat); + const maximumDate = timeService.createDateFromString('2001-03', dateFormat); + const minimumDateString = timeService.getDateAsFormattedString(minimumDate, dateFormat); + const maximumDateString = timeService.getDateAsFormattedString(maximumDate, dateFormat); const alwaysMaxRange = false; const range = 'P1Y'; const minimalRange = undefined; @@ -254,8 +262,8 @@ describe('TimeSliderService', () => { const timeSliderConfig: TimeSliderConfiguration = { name: 'mockTimeSlider', dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), + minimumDate: minimumDateString, + maximumDate: maximumDateString, alwaysMaxRange: alwaysMaxRange, range: range, minimalRange: minimalRange, @@ -266,19 +274,20 @@ describe('TimeSliderService', () => { layerIdentifiers: [], }, }; - const newValue: TimeExtent = {start: dayjs(minimumDate, dateFormat).toDate(), end: dayjs(minimumDate, dateFormat).toDate()}; - - const calculatedTimeExtent = service.createValidTimeExtent( - timeSliderConfig, - newValue, - true, - minimumDate.toDate(), - maximumDate.toDate(), - ); - expect(dayjs(calculatedTimeExtent.start).diff(dayjs('2000', dateFormat).toDate())).toBe(0); - expect(dayjs(calculatedTimeExtent.start).format(timeSliderConfig.dateFormat)).toBe('2000'); - expect(dayjs(calculatedTimeExtent.end).diff(dayjs('2001', dateFormat).toDate())).toBe(0); - expect(dayjs(calculatedTimeExtent.end).format(timeSliderConfig.dateFormat)).toBe('2001'); + const newValue: TimeExtent = { + start: timeService.createDateFromString(minimumDateString, dateFormat), + end: timeService.createDateFromString(minimumDateString, dateFormat), + }; + + const calculatedTimeExtent = service.createValidTimeExtent(timeSliderConfig, newValue, true, minimumDate, maximumDate); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.start, timeService.createDateFromString('2000', dateFormat)), + ).toBe(0); + expect(timeService.getDateAsFormattedString(calculatedTimeExtent.start, timeSliderConfig.dateFormat)).toBe('2000'); + expect( + timeService.calculateDifferenceBetweenDates(calculatedTimeExtent.end, timeService.createDateFromString('2001', dateFormat)), + ).toBe(0); + expect(timeService.getDateAsFormattedString(calculatedTimeExtent.end, timeSliderConfig.dateFormat)).toBe('2001'); }); }); @@ -308,67 +317,82 @@ describe('TimeSliderService', () => { it('should create the correct stops', () => { const stops = service.createStops(timeSliderConfig); expect(stops.length).toBe(3); - expect(dayjs(stops[0]).diff(dayjs.utc(firstStop, dateFormat))).toBe(0); - expect(dayjs(stops[1]).diff(dayjs.utc(secondStop, dateFormat))).toBe(0); - expect(dayjs(stops[2]).diff(dayjs.utc(thirdStop, dateFormat))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[0], timeService.createUTCDateFromString(firstStop, dateFormat))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[1], timeService.createUTCDateFromString(secondStop, dateFormat))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[2], timeService.createUTCDateFromString(thirdStop, dateFormat))).toBe(0); }); }); describe('using a parameter source', () => { describe('with a single time unit and a range', () => { - const dateFormat = 'YYYY-MM'; - const minimumDate = dayjs('2000-01', dateFormat); - const maximumDate = dayjs('2001-03', dateFormat); - const alwaysMaxRange = false; - const range = 'P1M'; // one month - const minimalRange = undefined; - - const timeSliderConfig: TimeSliderConfiguration = { - name: 'mockTimeSlider', - dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), - alwaysMaxRange: alwaysMaxRange, - range: range, - minimalRange: minimalRange, - sourceType: 'parameter', - source: { - startRangeParameter: '', - endRangeParameter: '', - layerIdentifiers: [], - }, - }; - it('should create the correct stops', () => { + const dateFormat = 'YYYY-MM'; + const minimumDate = timeService.createDateFromString('2000-01', dateFormat); + const maximumDate = timeService.createDateFromString('2001-03', dateFormat); + const minimumDateString = timeService.getDateAsFormattedString(minimumDate, dateFormat); + const maximumDateString = timeService.getDateAsFormattedString(maximumDate, dateFormat); + const alwaysMaxRange = false; + const range = 'P1M'; // one month + const minimalRange = undefined; + + const timeSliderConfig: TimeSliderConfiguration = { + name: 'mockTimeSlider', + dateFormat: dateFormat, + minimumDate: minimumDateString, + maximumDate: maximumDateString, + alwaysMaxRange: alwaysMaxRange, + range: range, + minimalRange: minimalRange, + sourceType: 'parameter', + source: { + startRangeParameter: '', + endRangeParameter: '', + layerIdentifiers: [], + }, + }; const stops = service.createStops(timeSliderConfig); expect(stops.length).toBe(15); - expect(dayjs(stops[0]).diff(dayjs.utc('2000-01', dateFormat))).toBe(0); - expect(dayjs(stops[1]).diff(dayjs.utc('2000-02', dateFormat))).toBe(0); - expect(dayjs(stops[stops.length - 1]).diff(dayjs.utc('2001-03', dateFormat))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[0], timeService.createUTCDateFromString('2000-01', dateFormat))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[1], timeService.createUTCDateFromString('2000-02', dateFormat))).toBe(0); + expect( + timeService.calculateDifferenceBetweenDates( + stops[stops.length - 1], + timeService.createUTCDateFromString('2001-03', dateFormat), + ), + ).toBe(0); }); }); describe('with mixed time units', () => { const dateFormat = 'YYYY-MM'; - const minimumDate = dayjs.utc('2000-01', dateFormat); - const maximumDate = dayjs.utc('2001-03', dateFormat); + let minimumDate: Date; + let maximumDate: Date; const parameterSource: TimeSliderParameterSource = { startRangeParameter: '', endRangeParameter: '', layerIdentifiers: [], }; + beforeEach(() => { + minimumDate = timeService.createUTCDateFromString('2000-01', dateFormat); + maximumDate = timeService.createUTCDateFromString('2001-03', dateFormat); + }); + describe('and a range', () => { const range = 'P1M10D'; - const timeSliderConfig: TimeSliderConfiguration = { - name: 'mockTimeSlider', - dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), - alwaysMaxRange: false, - range: range, // one month and 10 days - minimalRange: undefined, - sourceType: 'parameter', - source: parameterSource, - }; + let timeSliderConfig: TimeSliderConfiguration; + + beforeEach(() => { + timeSliderConfig = { + name: 'mockTimeSlider', + dateFormat: dateFormat, + minimumDate: timeService.getDateAsUTCString(minimumDate, dateFormat), + maximumDate: timeService.getDateAsUTCString(maximumDate, dateFormat), + alwaysMaxRange: false, + range: range, // one month and 10 days + minimalRange: undefined, + sourceType: 'parameter', + source: parameterSource, + }; + }); it('should create the correct stops', () => { const stops = service.createStops(timeSliderConfig); @@ -380,23 +404,27 @@ describe('TimeSliderService', () => { */ const expectedNumberOfStops = 12; expect(stops.length).toBe(expectedNumberOfStops); - expect(dayjs(stops[0]).diff(dayjs(minimumDate, dateFormat))).toBe(0); - expect(dayjs(stops[1]).diff(dayjs(minimumDate, dateFormat).add(dayjs.duration(range)))).toBe(0); - expect(dayjs(stops[stops.length - 1]).diff(dayjs(maximumDate, dateFormat))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[0], minimumDate)).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[1], timeService.addRangeToDate(minimumDate, range))).toBe(0); + expect(timeService.calculateDifferenceBetweenDates(stops[stops.length - 1], maximumDate)).toBe(0); }); }); describe('and no range', () => { - const timeSliderConfig: TimeSliderConfiguration = { - name: 'mockTimeSlider', - dateFormat: dateFormat, - minimumDate: minimumDate.format(dateFormat), - maximumDate: maximumDate.format(dateFormat), - alwaysMaxRange: false, - range: undefined, - minimalRange: undefined, - sourceType: 'parameter', - source: parameterSource, - }; + let timeSliderConfig: TimeSliderConfiguration; + + beforeEach(() => { + timeSliderConfig = { + name: 'mockTimeSlider', + dateFormat: dateFormat, + minimumDate: timeService.getDateAsUTCString(minimumDate, dateFormat), + maximumDate: timeService.getDateAsUTCString(maximumDate, dateFormat), + alwaysMaxRange: false, + range: undefined, + minimalRange: undefined, + sourceType: 'parameter', + source: parameterSource, + }; + }); it('should create the correct stops', () => { const stops = service.createStops(timeSliderConfig); @@ -407,12 +435,139 @@ describe('TimeSliderService', () => { */ const expectedNumberOfStops = 15; expect(stops.length).toBe(expectedNumberOfStops); - expect(dayjs(stops[0]).diff(dayjs.utc('2000-01', dateFormat))).toBe(0); - expect(dayjs(stops[1]).diff(dayjs.utc('2000-02', dateFormat))).toBe(0); - expect(dayjs(stops[stops.length - 1]).diff(dayjs.utc('2001-03', dateFormat))).toBe(0); + + expect(timeService.calculateDifferenceBetweenDates(stops[0], timeService.createUTCDateFromString('2000-01', dateFormat))).toBe( + 0, + ); + expect(timeService.calculateDifferenceBetweenDates(stops[1], timeService.createUTCDateFromString('2000-02', dateFormat))).toBe( + 0, + ); + expect( + timeService.calculateDifferenceBetweenDates( + stops[stops.length - 1], + timeService.createUTCDateFromString('2001-03', dateFormat), + ), + ).toBe(0); }); }); }); }); }); + + describe('isLayerVisible', () => { + it('returns `true` if a given layer is within the time extent', () => { + const mapLayer = {layer: 'layerName', visible: false} as MapLayer; + const timeSliderConfiguration = { + dateFormat: 'YYYY-MM-DD', + sourceType: 'layer', + source: { + layers: [{layerName: 'layerName', date: '2023-06-30'}], + } as TimeSliderLayerSource, + } as TimeSliderConfiguration; + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + + const expected = true; + const actual = service.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); + + expect(actual).toBe(expected); + }); + + it('returns `false` if a given layer is outside the time extent', () => { + const mapLayer = {layer: 'layerName', visible: false} as MapLayer; + const timeSliderConfiguration = { + dateFormat: 'YYYY-MM-DD', + sourceType: 'layer', + source: { + layers: [{layerName: 'layerName', date: '2024-01-01'}], + } as TimeSliderLayerSource, + } as TimeSliderConfiguration; + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + + const expected = false; + const actual = service.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); + + expect(actual).toBe(expected); + }); + + it('returns `undefined` if there is no matching layer', () => { + const mapLayer = {layer: 'layerName', visible: false} as MapLayer; + const timeSliderConfiguration = { + dateFormat: 'YYYY-MM-DD', + sourceType: 'layer', + source: { + layers: [{layerName: 'otherLayerName', date: '2023-06-15'}], + } as TimeSliderLayerSource, + } as TimeSliderConfiguration; + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + + const expected = undefined; + const actual = service.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); + + expect(actual).toBe(expected); + }); + + it('returns `undefined` if it is not a layer based time slider configuration', () => { + const mapLayer = {layer: 'layerName', visible: false} as MapLayer; + const timeSliderConfiguration = { + dateFormat: 'YYYY-MM-DD', + sourceType: 'parameter', + source: { + startRangeParameter: 'VON', + endRangeParameter: 'BIS', + layerIdentifiers: ['layerName'], + } as TimeSliderParameterSource, + } as TimeSliderConfiguration; + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + + const expected = undefined; + const actual = service.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); + + expect(actual).toBe(expected); + }); + + it('returns `undefined` if the time slider configuration is undefined', () => { + const mapLayer = {layer: 'layerName', visible: false} as MapLayer; + const timeSliderConfiguration = undefined; + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + + const expected = undefined; + const actual = service.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); + + expect(actual).toBe(expected); + }); + + it('returns `undefined` if the time extent is undefined', () => { + const mapLayer = {layer: 'layerName', visible: false} as MapLayer; + const timeSliderConfiguration = { + dateFormat: 'YYYY-MM-DD', + sourceType: 'parameter', + source: { + startRangeParameter: 'VON', + endRangeParameter: 'BIS', + layerIdentifiers: ['layerName'], + } as TimeSliderParameterSource, + } as TimeSliderConfiguration; + const timeExtent = undefined; + + const expected = undefined; + const actual = service.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); + + expect(actual).toBe(expected); + }); + }); }); diff --git a/src/app/map/services/time-slider.service.ts b/src/app/map/services/time-slider.service.ts index 4511f24aa..2c54270df 100644 --- a/src/app/map/services/time-slider.service.ts +++ b/src/app/map/services/time-slider.service.ts @@ -1,18 +1,28 @@ -import {Injectable} from '@angular/core'; -import {TimeSliderConfiguration, TimeSliderLayerSource} from '../../shared/interfaces/topic.interface'; -import dayjs from 'dayjs'; -import duration, {Duration} from 'dayjs/plugin/duration'; -import {TimeExtentUtils} from '../../shared/utils/time-extent.utils'; +import {Inject, Injectable} from '@angular/core'; +import {MapLayer, TimeSliderConfiguration, TimeSliderLayerSource} from '../../shared/interfaces/topic.interface'; import {TimeExtent} from '../interfaces/time-extent.interface'; - import {InvalidTimeSliderConfiguration} from '../../shared/errors/map.errors'; - -dayjs.extend(duration); +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../../shared/interfaces/time-service.interface'; +import {DateUnit} from '../../shared/types/date-unit.type'; @Injectable({ providedIn: 'root', }) export class TimeSliderService { + constructor(@Inject(TIME_SERVICE) private readonly timeService: TimeService) {} + /** + * Creates an initial time extent based on the given time slider configuration. + */ + public createInitialTimeSliderExtent(timeSliderConfig: TimeSliderConfiguration): TimeExtent { + const minimumDate: Date = this.timeService.createUTCDateFromString(timeSliderConfig.minimumDate, timeSliderConfig.dateFormat); + const maximumDate: Date = this.timeService.createUTCDateFromString(timeSliderConfig.maximumDate, timeSliderConfig.dateFormat); + return { + start: minimumDate, + end: timeSliderConfig.range ? this.timeService.addRangeToDate(minimumDate, timeSliderConfig.range) : maximumDate, + }; + } + /** * Creates stops which define specific locations on the time slider where thumbs will snap to when manipulated. */ @@ -25,6 +35,29 @@ export class TimeSliderService { } } + /** + * Is the given layer visible? Returns `true` or `false` depending on the layer to be within the given time extent; or `undefined` if + * either the layer isn't part of a time slider configuration, the extent is undefined or the configuration source isn't of type `layer`. + */ + public isLayerVisible( + mapLayer: MapLayer, + timeSliderConfiguration: TimeSliderConfiguration | undefined, + timeExtent: TimeExtent | undefined, + ): boolean | undefined { + if (!timeSliderConfiguration || timeSliderConfiguration.sourceType === 'parameter' || !timeExtent) { + return undefined; + } + + const timeSliderLayerSource = timeSliderConfiguration.source as TimeSliderLayerSource; + const timeSliderLayer = timeSliderLayerSource.layers.find((layer) => layer.layerName === mapLayer.layer); + if (timeSliderLayer) { + const date = this.timeService.createUTCDateFromString(timeSliderLayer.date, timeSliderConfiguration.dateFormat); + return date >= timeExtent.start && date < timeExtent.end; + } else { + return undefined; + } + } + public createValidTimeExtent( timeSliderConfig: TimeSliderConfiguration, newValue: TimeExtent, @@ -51,8 +84,7 @@ export class TimeSliderService { The start has changed as fixed ranges technically don't have an end date => the end date has to be adjusted accordingly to enforce the fixed range between start and end date */ - const range: Duration = dayjs.duration(timeSliderConfig.range); - timeExtent.end = TimeExtentUtils.addDuration(timeExtent.start, range); + timeExtent.end = this.timeService.addRangeToDate(timeExtent.start, timeSliderConfig.range); } else if (timeSliderConfig.minimalRange) { /* Minimal range @@ -73,21 +105,21 @@ export class TimeSliderService { timeExtent.end = startDate; } - const startEndDiff: number = TimeExtentUtils.calculateDifferenceBetweenDates(timeExtent.start, timeExtent.end); - const minimalRange: Duration = dayjs.duration(timeSliderConfig.minimalRange); + const startEndDiff: number = this.timeService.calculateDifferenceBetweenDates(timeExtent.start, timeExtent.end); + const minimalRangeInMs: number = this.timeService.getISORangeInMilliseconds(timeSliderConfig.minimalRange); - if (startEndDiff < minimalRange.asMilliseconds()) { + if (startEndDiff < minimalRangeInMs) { if (hasStartDateChanged) { - const newStartDate = TimeExtentUtils.subtractDuration(timeExtent.end, minimalRange); + const newStartDate = this.timeService.subtractRangeFromDate(timeExtent.end, timeSliderConfig.minimalRange); timeExtent.start = this.validateDateWithinLimits(newStartDate, minimumDate, maximumDate); - if (TimeExtentUtils.calculateDifferenceBetweenDates(timeExtent.start, timeExtent.end) < minimalRange.asMilliseconds()) { - timeExtent.end = TimeExtentUtils.addDuration(timeExtent.start, minimalRange); + if (this.timeService.calculateDifferenceBetweenDates(timeExtent.start, timeExtent.end) < minimalRangeInMs) { + timeExtent.end = this.timeService.addRangeToDate(timeExtent.start, timeSliderConfig.minimalRange); } } else { - const newEndDate = TimeExtentUtils.addDuration(timeExtent.start, minimalRange); + const newEndDate = this.timeService.addRangeToDate(timeExtent.start, timeSliderConfig.minimalRange); timeExtent.end = this.validateDateWithinLimits(newEndDate, minimumDate, maximumDate); - if (TimeExtentUtils.calculateDifferenceBetweenDates(timeExtent.start, timeExtent.end) < minimalRange.asMilliseconds()) { - timeExtent.start = TimeExtentUtils.subtractDuration(timeExtent.end, minimalRange); + if (this.timeService.calculateDifferenceBetweenDates(timeExtent.start, timeExtent.end) < minimalRangeInMs) { + timeExtent.start = this.timeService.subtractRangeFromDate(timeExtent.end, timeSliderConfig.minimalRange); } } } @@ -97,61 +129,113 @@ export class TimeSliderService { } public isTimeExtentValid(timeSliderConfig: TimeSliderConfiguration, timeExtent: TimeExtent): boolean { - const minDate = TimeExtentUtils.parseUTCDate(timeSliderConfig.minimumDate, timeSliderConfig.dateFormat); - const maxDate = TimeExtentUtils.parseUTCDate(timeSliderConfig.maximumDate, timeSliderConfig.dateFormat); + const minDate = this.timeService.createUTCDateFromString(timeSliderConfig.minimumDate, timeSliderConfig.dateFormat); + const maxDate = this.timeService.createUTCDateFromString(timeSliderConfig.maximumDate, timeSliderConfig.dateFormat); const updatedTimeExtent: TimeExtent = this.createValidTimeExtent(timeSliderConfig, timeExtent, false, minDate, maxDate); return timeExtent.start.getTime() === updatedTimeExtent.start.getTime() && timeExtent.end.getTime() === updatedTimeExtent.end.getTime(); } + /** + * Extracts a unit from the given date format (ISO8601) if it contains exactly one or if it contains multiple units. + * + * @remarks + * It does return a unit ('years'/'months'/...) only if the given range contains values of this unit and nothing else; + * otherwise. + * + * @example + * 'YYYY' is a date format containing only years; The unique unit is years and therefore this method returns 'years' + * 'H:m s.SSS' is a date format containing hours, minutes, seconds and milliseconds; there are multiple units therefore this method + * returns 'undefined' + * */ + public extractUniqueUnitFromDateFormat(dateFormat: string): DateUnit | undefined { + if (dateFormat.replace(/S/g, '').trim() === '') return 'milliseconds'; + if (dateFormat.replace(/s/g, '').trim() === '') return 'seconds'; + if (dateFormat.replace(/m/g, '').trim() === '') return 'minutes'; + if (dateFormat.replace(/[hH]/g, '').trim() === '') return 'hours'; + if (dateFormat.replace(/[dD]/g, '').trim() === '') return 'days'; + if (dateFormat.replace(/M/g, '').trim() === '') return 'months'; + if (dateFormat.replace(/Y/g, '').trim() === '') return 'years'; + return undefined; + } + /** * Creates stops for a layer source containing multiple dates which may not necessarily have constant gaps between them. */ private createStopsForLayerSource(timeSliderConfig: TimeSliderConfiguration): Array { const timeSliderLayerSource = timeSliderConfig.source as TimeSliderLayerSource; - return timeSliderLayerSource.layers.map((layer) => TimeExtentUtils.parseUTCDate(layer.date, timeSliderConfig.dateFormat)); + return timeSliderLayerSource.layers.map((layer) => this.timeService.createUTCDateFromString(layer.date, timeSliderConfig.dateFormat)); } /** * Creates stops for a parameter source. * * @remarks - * This is done by using a strict interval (e.g. one year) if the default range duration only contains + * This is done by using a strict interval (e.g. one year) if the default range only contains * a single type of unit (e.g. 'years'). Otherwise a more generic approach is used by creating date stops from - * start to finish using the given duration; this can lead to gaps near the end but supports all cases. + * start to finish using the given range; this can lead to gaps near the end but supports all cases. */ private createStopsForParameterSource(timeSliderConfig: TimeSliderConfiguration): Array { - const minimumDate: Date = TimeExtentUtils.parseUTCDate(timeSliderConfig.minimumDate, timeSliderConfig.dateFormat); - const maximumDate: Date = TimeExtentUtils.parseUTCDate(timeSliderConfig.maximumDate, timeSliderConfig.dateFormat); + const minimumDate: Date = this.timeService.createUTCDateFromString(timeSliderConfig.minimumDate, timeSliderConfig.dateFormat); + const maximumDate: Date = this.timeService.createUTCDateFromString(timeSliderConfig.maximumDate, timeSliderConfig.dateFormat); const initialRange: string | null = timeSliderConfig.range ?? timeSliderConfig.minimalRange ?? null; - let stopRangeDuration: Duration | null = initialRange ? dayjs.duration(initialRange) : null; if ( - stopRangeDuration && - TimeExtentUtils.calculateDifferenceBetweenDates(minimumDate, maximumDate) <= stopRangeDuration.asMilliseconds() + initialRange && + this.timeService.calculateDifferenceBetweenDates(minimumDate, maximumDate) <= this.timeService.getISORangeInMilliseconds(initialRange) ) { throw new InvalidTimeSliderConfiguration('min date + range > max date'); } - if (!stopRangeDuration) { - const unit = TimeExtentUtils.extractSmallestUnitFromDateFormat(timeSliderConfig.dateFormat); + + let unit: DateUnit | undefined; + if (!initialRange) { + unit = this.extractSmallestUnitFromDateFormat(timeSliderConfig.dateFormat); if (!unit) { throw new InvalidTimeSliderConfiguration('Datumsformat sowie minimale Range sind ungültig.'); } - - // create a new duration base on the smallest unit with the lowest valid unit number (1) - stopRangeDuration = dayjs.duration(1, unit); } const dates: Date[] = []; let date = minimumDate; while (date < maximumDate) { dates.push(date); - date = TimeExtentUtils.addDuration(date, stopRangeDuration); + + if (initialRange) { + date = this.timeService.addRangeToDate(date, initialRange); + } else if (unit) { + date = this.addMinimalRange(date, unit); + } else { + throw new InvalidTimeSliderConfiguration('Datumsformat sowie minimale Range sind ungültig.'); + } } dates.push(maximumDate); return dates; } + private addMinimalRange(date: Date, unit: string): Date { + return this.timeService.addMinimalRangeToDate(date, unit); + } + + /** + * Extracts the smallest unit from the given date format (ISO8601) or if nothing matches. + * + * @example + * 'YYYY-MM' is a date format containing years and months; The smallest unit is months (months < years) and therefore this method returns + * 'months' + * 'H:m s.SSS' is a date format containing hours, minutes, seconds and milliseconds; The smallest unit is milliseconds and therefore this + * method returns 'milliseconds' + * */ + private extractSmallestUnitFromDateFormat(dateFormat: string): DateUnit | undefined { + if (dateFormat.includes('SSS')) return 'milliseconds'; + if (dateFormat.includes('s')) return 'seconds'; + if (dateFormat.includes('m')) return 'minutes'; + if (dateFormat.toLowerCase().includes('h')) return 'hours'; // both `h` and `H` are used for `hours` + if (dateFormat.toLowerCase().includes('d')) return 'days'; // both `d` and `D` are used for `days` + if (dateFormat.includes('M')) return 'months'; + if (dateFormat.includes('Y')) return 'years'; + return undefined; + } + /** * Validates that the date is within the given min and max date; returns the date if it is within or the corresponding min/max date * otherwise. diff --git a/src/app/map/utils/active-time-slider-layers.utils.spec.ts b/src/app/map/utils/active-time-slider-layers.utils.spec.ts deleted file mode 100644 index ae842ca4d..000000000 --- a/src/app/map/utils/active-time-slider-layers.utils.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import {MapLayer, TimeSliderConfiguration, TimeSliderLayerSource, TimeSliderParameterSource} from '../../shared/interfaces/topic.interface'; -import {TimeExtent} from '../interfaces/time-extent.interface'; -import {ActiveTimeSliderLayersUtils} from './active-time-slider-layers.utils'; - -describe('ActiveTimeSliderLayersUtils', () => { - describe('isLayerVisible', () => { - it('returns `true` if a given layer is within the time extent', () => { - const mapLayer = {layer: 'layerName', visible: false} as MapLayer; - const timeSliderConfiguration = { - dateFormat: 'YYYY-MM-DD', - sourceType: 'layer', - source: { - layers: [{layerName: 'layerName', date: '2023-06-30'}], - } as TimeSliderLayerSource, - } as TimeSliderConfiguration; - const timeExtent: TimeExtent = { - start: new Date(2023, 0, 1), - end: new Date(2023, 11, 31), - }; - - const expected = true; - const actual = ActiveTimeSliderLayersUtils.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); - - expect(actual).toBe(expected); - }); - - it('returns `false` if a given layer is outside the time extent', () => { - const mapLayer = {layer: 'layerName', visible: false} as MapLayer; - const timeSliderConfiguration = { - dateFormat: 'YYYY-MM-DD', - sourceType: 'layer', - source: { - layers: [{layerName: 'layerName', date: '2024-01-01'}], - } as TimeSliderLayerSource, - } as TimeSliderConfiguration; - const timeExtent: TimeExtent = { - start: new Date(2023, 0, 1), - end: new Date(2023, 11, 31), - }; - - const expected = false; - const actual = ActiveTimeSliderLayersUtils.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); - - expect(actual).toBe(expected); - }); - - it('returns `undefined` if there is no matching layer', () => { - const mapLayer = {layer: 'layerName', visible: false} as MapLayer; - const timeSliderConfiguration = { - dateFormat: 'YYYY-MM-DD', - sourceType: 'layer', - source: { - layers: [{layerName: 'otherLayerName', date: '2023-06-15'}], - } as TimeSliderLayerSource, - } as TimeSliderConfiguration; - const timeExtent: TimeExtent = { - start: new Date(2023, 0, 1), - end: new Date(2023, 11, 31), - }; - - const expected = undefined; - const actual = ActiveTimeSliderLayersUtils.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); - - expect(actual).toBe(expected); - }); - - it('returns `undefined` if it is not a layer based time slider configuration', () => { - const mapLayer = {layer: 'layerName', visible: false} as MapLayer; - const timeSliderConfiguration = { - dateFormat: 'YYYY-MM-DD', - sourceType: 'parameter', - source: { - startRangeParameter: 'VON', - endRangeParameter: 'BIS', - layerIdentifiers: ['layerName'], - } as TimeSliderParameterSource, - } as TimeSliderConfiguration; - const timeExtent: TimeExtent = { - start: new Date(2023, 0, 1), - end: new Date(2023, 11, 31), - }; - - const expected = undefined; - const actual = ActiveTimeSliderLayersUtils.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); - - expect(actual).toBe(expected); - }); - - it('returns `undefined` if the time slider configuration is undefined', () => { - const mapLayer = {layer: 'layerName', visible: false} as MapLayer; - const timeSliderConfiguration = undefined; - const timeExtent: TimeExtent = { - start: new Date(2023, 0, 1), - end: new Date(2023, 11, 31), - }; - - const expected = undefined; - const actual = ActiveTimeSliderLayersUtils.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); - - expect(actual).toBe(expected); - }); - - it('returns `undefined` if the time extent is undefined', () => { - const mapLayer = {layer: 'layerName', visible: false} as MapLayer; - const timeSliderConfiguration = { - dateFormat: 'YYYY-MM-DD', - sourceType: 'parameter', - source: { - startRangeParameter: 'VON', - endRangeParameter: 'BIS', - layerIdentifiers: ['layerName'], - } as TimeSliderParameterSource, - } as TimeSliderConfiguration; - const timeExtent = undefined; - - const expected = undefined; - const actual = ActiveTimeSliderLayersUtils.isLayerVisible(mapLayer, timeSliderConfiguration, timeExtent); - - expect(actual).toBe(expected); - }); - }); -}); diff --git a/src/app/map/utils/active-time-slider-layers.utils.ts b/src/app/map/utils/active-time-slider-layers.utils.ts deleted file mode 100644 index 283f9ec19..000000000 --- a/src/app/map/utils/active-time-slider-layers.utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {MapLayer, TimeSliderConfiguration, TimeSliderLayerSource} from '../../shared/interfaces/topic.interface'; -import {TimeExtent} from '../interfaces/time-extent.interface'; -import {TimeExtentUtils} from '../../shared/utils/time-extent.utils'; - -export class ActiveTimeSliderLayersUtils { - /** - * Is the given layer visible? Returns `true` or `false` depending on the layer to be within the given time extent; or `undefined` if - * either the layer isn't part of a time slider configuration, the extent is undefined or the configuration source isn't of type `layer`. - */ - public static isLayerVisible( - mapLayer: MapLayer, - timeSliderConfiguration: TimeSliderConfiguration | undefined, - timeExtent: TimeExtent | undefined, - ): boolean | undefined { - if (!timeSliderConfiguration || timeSliderConfiguration.sourceType === 'parameter' || !timeExtent) { - return undefined; - } - - const timeSliderLayerSource = timeSliderConfiguration.source as TimeSliderLayerSource; - const timeSliderLayer = timeSliderLayerSource.layers.find((layer) => layer.layerName === mapLayer.layer); - if (timeSliderLayer) { - const date = TimeExtentUtils.parseUTCDate(timeSliderLayer.date, timeSliderConfiguration.dateFormat); - return date >= timeExtent.start && date < timeExtent.end; - } else { - return undefined; - } - } -} diff --git a/src/app/onboarding-guide/services/onboarding-guide.service.ts b/src/app/onboarding-guide/services/onboarding-guide.service.ts index fe2ed60d4..dc7d86d10 100644 --- a/src/app/onboarding-guide/services/onboarding-guide.service.ts +++ b/src/app/onboarding-guide/services/onboarding-guide.service.ts @@ -3,7 +3,6 @@ import {TourService} from 'ngx-ui-tour-md-menu'; import {tap} from 'rxjs'; import {LocalStorageService} from '../../shared/services/local-storage.service'; import {OnboardingGuideConfig} from '../interfaces/onboarding-guide-config.interface'; -import {StorageUtils} from '../../shared/utils/storage.utils'; export const ONBOARDING_STEPS = new InjectionToken('onboardingSteps'); @@ -57,7 +56,7 @@ export class OnboardingGuideService { if (!viewedGuides.includes(this.onboardingGuideId)) { viewedGuides.push(this.onboardingGuideId); - this.localStorageService.set('onboardingGuidesViewed', StorageUtils.stringifyJson(viewedGuides)); + this.localStorageService.set('onboardingGuidesViewed', this.localStorageService.stringifyJson(viewedGuides)); } } diff --git a/src/app/shared/factories/time-service.factory.spec.ts b/src/app/shared/factories/time-service.factory.spec.ts new file mode 100644 index 000000000..3a0f05e79 --- /dev/null +++ b/src/app/shared/factories/time-service.factory.spec.ts @@ -0,0 +1,10 @@ +import {timeServiceFactory} from './time-service.factory'; +import {DayjsService} from '../services/dayjs.service'; + +describe('TimeServiceFactory', () => { + it('returns an instance of dayjs service', () => { + const result = timeServiceFactory(); + + expect(result).toBeInstanceOf(DayjsService); + }); +}); diff --git a/src/app/shared/factories/time-service.factory.ts b/src/app/shared/factories/time-service.factory.ts new file mode 100644 index 000000000..4cdc8b5db --- /dev/null +++ b/src/app/shared/factories/time-service.factory.ts @@ -0,0 +1,5 @@ +import {DayjsService} from '../services/dayjs.service'; + +export function timeServiceFactory() { + return new DayjsService(); +} diff --git a/src/app/shared/interfaces/gb3-metadata.interface.ts b/src/app/shared/interfaces/gb3-metadata.interface.ts index a12e36a2c..bc2aa08cd 100644 --- a/src/app/shared/interfaces/gb3-metadata.interface.ts +++ b/src/app/shared/interfaces/gb3-metadata.interface.ts @@ -15,8 +15,8 @@ export interface DepartmentalContact { village: string; phone: string; phoneDirect: string; - email: LinkObject; - url: string; + email: LinkObject | null; + url: LinkObject | null; } interface BaseMetadataInterface { diff --git a/src/app/shared/interfaces/layer-attributes.interface.ts b/src/app/shared/interfaces/layer-attributes.interface.ts index e6b7c9f5b..fcaba26af 100644 --- a/src/app/shared/interfaces/layer-attributes.interface.ts +++ b/src/app/shared/interfaces/layer-attributes.interface.ts @@ -1,6 +1,6 @@ export interface LayerAttributes { - name: string; - description: string; - type: string; + name: string | null; + description: string | null; + type: string | null; unit: string | null; } diff --git a/src/app/shared/interfaces/time-service.interface.ts b/src/app/shared/interfaces/time-service.interface.ts new file mode 100644 index 000000000..8b96a2c65 --- /dev/null +++ b/src/app/shared/interfaces/time-service.interface.ts @@ -0,0 +1,73 @@ +import {DateUnit} from '../types/date-unit.type'; + +export interface TimeService { + /** + * Given a date and a unit, this method returns the partial value of the given date. + */ + createPartialFromString: (date: string, unit: DateUnit) => number; + /** + * Creates a date object from a string; with an optional format in ISO 8601. + */ + createDateFromString: (date: string, format?: string) => Date; + /** + * Creates a date object from a Unix timestamp. + */ + createDateFromUnixTimestamp: (timestamp: number) => Date; + /** + * Creates a date object from a string in UTC; with an optional format in ISO 8601. + */ + createUTCDateFromString: (date: string, format?: string) => Date; + /** + * Returns the date as a formatted string according to the given format in ISO 8601. + */ + getDateAsFormattedString: (date: Date, format: string) => string; + /** + * Converts a date into UTC and returns it as (optionally formatted) string. + */ + getDateAsUTCString: (date: Date, format?: string) => string; + /** + * Returns the difference between two dates in milliseconds. + */ + calculateDifferenceBetweenDates: (firstDate: Date, secondDate: Date) => number; + /** + * Checks whether the given string is a valid date. + */ + isDate: (value: string) => boolean; + /** + * Returns `true` if the given string range is exactly one of a single time unit (year, month, ...). + * + * @example + * `P1Y1M` is a range of one year AND one month which is more than one time unit; therefore is the result `false` + * `P2Y` is a range of two years which is more than one of a single time unit; therefore is the result `false` + * `P1D` is a range of one day which is exactly one of a single time unit; therefore the result is `true` + */ + isStringSingleTimeUnitRange: (range: string) => boolean; + /** + * Adds the range to the given date as exact as possible. + * + * @remarks + * It will add values of a specific unit to the date in case that + * the range contains only values of one specific unit (e.g. 'years'). This has the advantage that it does not use + * a generic solution which would be 365 days in case of a year. + * */ + addRangeToDate: (date: Date, range: string) => Date; + /** + * Subtracts the range from the given date as exact as possible. + * + * @remarks + * It will subtract values of a specific unit from the date in case that + * the range contains only values of one specific unit (e.g. 'years'). This has the advantage that it does not use + * a generic solution which would be 365 days in case of a year. + * */ + subtractRangeFromDate: (date: Date, range: string) => Date; + /** + * Returns an ISO 8601 range in milliseconds. + */ + getISORangeInMilliseconds: (range: string) => number; + /** + * Adds a range of 1 of the given unit to the date. + * @param date + * @param unit + */ + addMinimalRangeToDate: (date: Date, unit: string) => Date; +} diff --git a/src/app/shared/interfaces/topic.interface.ts b/src/app/shared/interfaces/topic.interface.ts index 13fea44a9..917e9c15e 100644 --- a/src/app/shared/interfaces/topic.interface.ts +++ b/src/app/shared/interfaces/topic.interface.ts @@ -2,13 +2,27 @@ import {HasVisibility} from '../../map/interfaces/has-visibility.interface'; import {HasHidingState} from './has-hiding-state.interface'; import {HasActiveState} from './has-active-state.interface'; import {HasOpacity} from '../../map/interfaces/has-opacity.interface'; +import {TimeExtent} from '../../map/interfaces/time-extent.interface'; export interface Topic { title: string; maps: Map[]; } -export interface Map extends HasOpacity { +/** + * TimeSliderSettings are either or - they have either a config AND an extent, or neither. + */ +export type TimeSliderSettings = + | { + timeSliderConfiguration: TimeSliderConfiguration; + initialTimeSliderExtent: TimeExtent; + } + | { + timeSliderConfiguration: undefined; + initialTimeSliderExtent: undefined; + }; + +interface BasicMap extends HasOpacity { /** Map identifier */ id: string; /** Map title */ @@ -34,8 +48,6 @@ export interface Map extends HasOpacity { minScale: number | null; /** True if unaccessible with current permissions. Not available in production environment. */ permissionMissing?: boolean; - /** Timeslider Settings */ - timeSliderConfiguration?: TimeSliderConfiguration; /** Filters Settings */ filterConfigurations?: FilterConfiguration[]; searchConfigurations?: SearchConfiguration[]; @@ -43,6 +55,8 @@ export interface Map extends HasOpacity { notice: string | null; } +export type Map = BasicMap & TimeSliderSettings; + export interface MapLayer extends HasVisibility, HasHidingState { /** Layer ID */ id: number; diff --git a/src/app/shared/models/gb3-api-generated.interfaces.ts b/src/app/shared/models/gb3-api-generated.interfaces.ts index dd28dcf97..3cc7c6bdc 100644 --- a/src/app/shared/models/gb3-api-generated.interfaces.ts +++ b/src/app/shared/models/gb3-api-generated.interfaces.ts @@ -168,6 +168,15 @@ export interface MetadataDatasets { datasets: Dataset[]; } +export interface MetadataGeoshopProducts { + geoshop_products?: { + /** Geoshop product ID */ + giszhnr: number; + /** Link auf Geodatashop bei NOGD-Daten */ + url_shop: string | null; + }[]; +} + export interface MetadataMap { map: Map; } @@ -421,6 +430,11 @@ export interface PrintNew { }; } +export interface PrintReport { + /** Link to report file */ + report_url: string; +} + export interface ProductsList { /** Timestamp of product list in ISO8601 format */ timestamp: string; @@ -656,9 +670,9 @@ export interface Contact { /** Telephon direkt */ telephon_direkt: string; /** E-Mail */ - email: LinkObject; + email: LinkObject | null; /** URL */ - weburl: LinkObject; + weburl: LinkObject | null; } export interface Dataset { @@ -751,13 +765,13 @@ export interface Dataset { datenbezugart: string; attribute: { /** Attributname */ - name: string; + name: string | null; /** Attributtyp */ - typ: string; + typ: string | null; /** Einheit des Attributwerts */ einheit: string | null; /** Beschreibung des Attributs */ - beschreibung: string; + beschreibung: string | null; }[]; }[]; services: { @@ -968,6 +982,10 @@ export interface Map { name: string; /** Beschreibung der Karte */ beschreibung: string; + /** Link to the internet version of the current map */ + gbkarten_internet_url: LinkObject | null; + /** Link to the intranet version of the current map (only available whether this api is called from intranet) */ + gbkarten_intranet_url: LinkObject | null; /** Link auf GB2-Karte */ gb2_url: LinkObject | null; /** Links auf weiterführende Verweise */ @@ -1121,6 +1139,21 @@ export interface Service { }[]; } +export interface VectorInputGeneral { + /** GeoJSON file containing FeatureCollection or Feature, or KML file */ + file: File; +} + +export interface VectorInputGeojson { + /** GeoJSON file containing FeatureCollection or Feature */ + file: File; +} + +export interface VectorInputKml { + /** KML file */ + file: File; +} + /** Vector layer */ export interface VectorLayer { /** Vector layer type */ @@ -1207,27 +1240,12 @@ export interface VectorLayerWithoutStyles { export type CantonListData = Canton; -export interface ImportGeojsonCreatePayload { - /** GeoJSON file containing FeatureCollection or Feature */ - file: File; -} - export type ImportGeojsonCreateData = VectorLayer; export type ExportGeojsonCreateData = GenericGeojsonFeatureCollection; -export interface ImportCreatePayload { - /** GeoJSON file containing FeatureCollection or Feature, or KML file */ - file: File; -} - export type ImportCreateData = VectorLayer; -export interface ImportKmlCreatePayload { - /** KML file */ - file: File; -} - export type ImportKmlCreateData = VectorLayer; export type ExportKmlCreateData = any; @@ -1242,14 +1260,7 @@ export type MetadataDatasetsListData = MetadataDatasets; export type MetadataDatasetsDetailData = MetadataDataset; -export interface MetadataGeoshopProductsListData { - geoshop_products: { - /** Geoshop product ID */ - giszhnr: number; - /** Link auf Geodatashop bei NOGD-Daten */ - url_shop: string | null; - }[]; -} +export type MetadataGeoshopProductsListData = MetadataGeoshopProducts; export type MetadataMapsListData = MetadataMaps; @@ -1277,20 +1288,11 @@ export type UserFavoritesDetailData = PersonalFavorite; export type UserFavoritesDeleteData = any; -export interface PrintCreateData { - /** Link to report file */ - report_url: string; -} +export type PrintCreateData = PrintReport; -export interface PrintFeatureInfoCreateData { - /** Link to report file */ - report_url: string; -} +export type PrintFeatureInfoCreateData = PrintReport; -export interface PrintLegendCreateData { - /** Link to report file */ - report_url: string; -} +export type PrintLegendCreateData = PrintReport; export type PrintDetailData = any; diff --git a/src/app/shared/pipes/text-or-placeholder.pipe.spec.ts b/src/app/shared/pipes/text-or-placeholder.pipe.spec.ts new file mode 100644 index 000000000..729b932a8 --- /dev/null +++ b/src/app/shared/pipes/text-or-placeholder.pipe.spec.ts @@ -0,0 +1,28 @@ +import {TextOrPlaceholderPipe} from './text-or-placeholder.pipe'; + +describe('TextOrPlaceholderPipe', () => { + let pipe: TextOrPlaceholderPipe; + + beforeEach(() => { + pipe = new TextOrPlaceholderPipe(); + }); + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + it('returns the string value if present', () => { + const value: string = 'test'; + + const result = pipe.transform(value); + + const expected = 'test'; + expect(result).toEqual(expected); + }); + it('returns the placeholder if the value is null', () => { + const value = null; + + const result = pipe.transform(value); + + const expected = '-'; + expect(result).toEqual(expected); + }); +}); diff --git a/src/app/shared/pipes/text-or-placeholder.pipe.ts b/src/app/shared/pipes/text-or-placeholder.pipe.ts new file mode 100644 index 000000000..cd23f93cc --- /dev/null +++ b/src/app/shared/pipes/text-or-placeholder.pipe.ts @@ -0,0 +1,11 @@ +import {Pipe, PipeTransform} from '@angular/core'; + +@Pipe({ + name: 'textOrPlaceholder', + standalone: true, +}) +export class TextOrPlaceholderPipe implements PipeTransform { + transform(value: string | null): string { + return value ?? '-'; + } +} diff --git a/src/app/shared/services/abstract-storage.service.ts b/src/app/shared/services/abstract-storage.service.ts new file mode 100644 index 000000000..b837237d2 --- /dev/null +++ b/src/app/shared/services/abstract-storage.service.ts @@ -0,0 +1,40 @@ +import {LocalStorageKey} from '../types/local-storage-key.type'; +import {SessionStorageKey} from '../types/session-storage-key.type'; +import {TimeService} from '../interfaces/time-service.interface'; + +export abstract class AbstractStorageService { + protected constructor(private readonly timeService: TimeService) {} + + public abstract set(key: T, value: string): void; + + public abstract get(key: T): string | null; + + public abstract remove(key: T): void; + + public parseJson(value: string): JsonType { + return JSON.parse(value, this.reviver.bind(this)); + } + + public stringifyJson(value: JsonType): string { + return JSON.stringify(value); + } + + /** + * Returns a date in the correct format after parsing from stringified Object. This is used within the JSON.parse + * method, hence the "any" type for the value parameter. + * + * Because we parse a stringified date that was created by using JSON.stringify() which uses the ISO8601 format, which looks like + * YYYY-MM-DDTHH:mm:ss.SSSZ, time libraries might have issues due to the Z parameter (see e.g. + * https://github.com/iamkun/dayjs/issues/1729). Instead of hacking this and replacing the "Z" with an empty string, we just create a + * default date without UTC. + * However, this has the issue that e.g. "12" is valid and parsed as a date. Therefore, we check whether the parsed string's ISO string + * representation is equal to the original string. If it is, we return the parsed date, otherwise the original string (see GB3-1597). + */ + private reviver(key: string, value: any): any { + if (typeof value === 'string' && this.timeService.isDate(value)) { + const parsed = this.timeService.createUTCDateFromString(value); + return parsed.toISOString() === value ? parsed : value; + } + return value; + } +} diff --git a/src/app/shared/services/apis/abstract-api.service.ts b/src/app/shared/services/apis/abstract-api.service.ts index 606b39caa..755bb920a 100644 --- a/src/app/shared/services/apis/abstract-api.service.ts +++ b/src/app/shared/services/apis/abstract-api.service.ts @@ -1,7 +1,9 @@ import {HttpClient} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {Inject, Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {ConfigService} from '../config.service'; +import {TimeService} from '../../interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../../app.module'; @Injectable({ providedIn: 'root', @@ -12,6 +14,7 @@ export abstract class BaseApiService { constructor( private readonly http: HttpClient, protected readonly configService: ConfigService, + @Inject(TIME_SERVICE) protected readonly timeService: TimeService, ) {} protected get(url: string): Observable { diff --git a/src/app/shared/services/apis/gb3/gb3-export.service.ts b/src/app/shared/services/apis/gb3/gb3-export.service.ts index f1ac9d20c..6993bf635 100644 --- a/src/app/shared/services/apis/gb3/gb3-export.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-export.service.ts @@ -6,6 +6,8 @@ import {ConfigService} from '../../config.service'; import {map} from 'rxjs/operators'; import {ExportFormat} from '../../../enums/export-format.enum'; import {FileDownloadService} from '../../file-download-service'; +import {TIME_SERVICE} from '../../../../app.module'; +import {TimeService} from 'src/app/shared/interfaces/time-service.interface'; @Injectable({ providedIn: 'root', @@ -16,9 +18,10 @@ export class Gb3ExportService extends Gb3ApiService { constructor( @Inject(HttpClient) http: HttpClient, @Inject(ConfigService) configService: ConfigService, + @Inject(TIME_SERVICE) timeService: TimeService, private readonly fileDownloadService: FileDownloadService, ) { - super(http, configService); + super(http, configService, timeService); } public exportDrawing(exportFormat: ExportFormat, drawings: Gb3VectorLayer) { diff --git a/src/app/shared/services/apis/gb3/gb3-favourites.service.ts b/src/app/shared/services/apis/gb3/gb3-favourites.service.ts index 7e9044273..f2b06746e 100644 --- a/src/app/shared/services/apis/gb3/gb3-favourites.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-favourites.service.ts @@ -9,7 +9,6 @@ import { import {Observable} from 'rxjs'; import {map} from 'rxjs/operators'; import {CreateFavourite, Favourite, FavouritesResponse} from '../../../interfaces/favourite.interface'; -import {TimeExtentUtils} from '../../../utils/time-extent.utils'; import {ApiGeojsonGeometryToGb3ConverterUtils} from '../../../utils/api-geojson-geometry-to-gb3-converter.utils'; @Injectable({ @@ -67,8 +66,8 @@ export class Gb3FavouritesService extends Gb3ApiService { attributeFilters: content.attributeFilters, timeExtent: content.timeExtent ? { - start: TimeExtentUtils.parseDefaultUTCDate(content.timeExtent.start), - end: TimeExtentUtils.parseDefaultUTCDate(content.timeExtent.end), + start: this.timeService.createUTCDateFromString(content.timeExtent.start), + end: this.timeService.createUTCDateFromString(content.timeExtent.end), } : undefined, }; diff --git a/src/app/shared/services/apis/gb3/gb3-import.service.ts b/src/app/shared/services/apis/gb3/gb3-import.service.ts index cec4a2e85..3f1871a80 100644 --- a/src/app/shared/services/apis/gb3/gb3-import.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-import.service.ts @@ -3,6 +3,8 @@ import {Inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {ConfigService} from '../../config.service'; import {Gb3VectorLayer} from '../../../interfaces/gb3-vector-layer.interface'; +import {TimeService} from '../../../interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../../../app.module'; @Injectable({ providedIn: 'root', @@ -10,8 +12,12 @@ import {Gb3VectorLayer} from '../../../interfaces/gb3-vector-layer.interface'; export class Gb3ImportService extends Gb3ApiService { protected readonly endpoint = 'import'; - constructor(@Inject(HttpClient) http: HttpClient, @Inject(ConfigService) configService: ConfigService) { - super(http, configService); + constructor( + @Inject(HttpClient) http: HttpClient, + @Inject(ConfigService) configService: ConfigService, + @Inject(TIME_SERVICE) timeService: TimeService, + ) { + super(http, configService, timeService); } public importDrawing(file: File | Blob) { diff --git a/src/app/shared/services/apis/gb3/gb3-metadata.service.spec.ts b/src/app/shared/services/apis/gb3/gb3-metadata.service.spec.ts index 0fa7a4163..4ed0a98b2 100644 --- a/src/app/shared/services/apis/gb3/gb3-metadata.service.spec.ts +++ b/src/app/shared/services/apis/gb3/gb3-metadata.service.spec.ts @@ -79,6 +79,8 @@ const mockMapDetailResponse = { topic: 'test-topic', verweise: [], gb2_url: null, + gbkarten_internet_url: null, + gbkarten_intranet_url: null, }, } as MetadataMapsDetailData; @@ -153,7 +155,7 @@ const mockDatasetDetailResponse = { } as MetadataDatasetsDetailData; const expectedMockDepartmentalContact: DepartmentalContact = { - url: mockContact.weburl.href, + url: mockContact.weburl, email: mockContact.email, phoneDirect: mockContact.telephon_direkt, phone: mockContact.telephon, diff --git a/src/app/shared/services/apis/gb3/gb3-metadata.service.ts b/src/app/shared/services/apis/gb3/gb3-metadata.service.ts index 297b78a33..48ffb7bd2 100644 --- a/src/app/shared/services/apis/gb3/gb3-metadata.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-metadata.service.ts @@ -297,7 +297,7 @@ export class Gb3MetadataService extends Gb3ApiService { department: contact.amt, division: contact.fachstelle, section: contact.sektion, - url: contact.weburl.href, + url: contact.weburl, street: contact.strassenname, poBox: contact.postfach, email: contact.email, diff --git a/src/app/shared/services/apis/gb3/gb3-print.service.ts b/src/app/shared/services/apis/gb3/gb3-print.service.ts index 922c56b97..247ae251f 100644 --- a/src/app/shared/services/apis/gb3/gb3-print.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-print.service.ts @@ -22,6 +22,8 @@ import {Gb3StyledInternalDrawingRepresentation} from '../../../interfaces/intern import {PrintData} from '../../../../map/interfaces/print-data.interface'; import {Gb2WmsSettings} from '../../../../map/models/implementations/gb2-wms.model'; import {DrawingLayerSettings} from '../../../../map/models/implementations/drawing.model'; +import {TIME_SERVICE} from '../../../../app.module'; +import {TimeService} from '../../../interfaces/time-service.interface'; @Injectable({ providedIn: 'root', @@ -32,9 +34,10 @@ export class Gb3PrintService extends Gb3ApiService { constructor( @Inject(HttpClient) http: HttpClient, @Inject(ConfigService) configService: ConfigService, + @Inject(TIME_SERVICE) timeService: TimeService, private readonly basemapConfigService: BasemapConfigService, ) { - super(http, configService); + super(http, configService, timeService); } public createPrintJob(printCreation: PrintCreation): Observable { diff --git a/src/app/shared/services/apis/gb3/gb3-share-link.service.ts b/src/app/shared/services/apis/gb3/gb3-share-link.service.ts index eb848ea6b..587b83207 100644 --- a/src/app/shared/services/apis/gb3/gb3-share-link.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-share-link.service.ts @@ -11,7 +11,8 @@ import {HttpClient} from '@angular/common/http'; import {BasemapConfigService} from '../../../../map/services/basemap-config.service'; import {FavouritesService} from '../../../../map/services/favourites.service'; import {MapRestoreItem} from '../../../interfaces/map-restore-item.interface'; -import {TimeExtentUtils} from '../../../utils/time-extent.utils'; +import {TimeService} from '../../../interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../../../app.module'; @Injectable({ providedIn: 'root', @@ -22,10 +23,11 @@ export class Gb3ShareLinkService extends Gb3ApiService { constructor( @Inject(HttpClient) http: HttpClient, @Inject(ConfigService) configService: ConfigService, + @Inject(TIME_SERVICE) timeService: TimeService, private readonly basemapConfigService: BasemapConfigService, private readonly favouritesService: FavouritesService, ) { - super(http, configService); + super(http, configService, timeService); } public loadShareLink(shareLinkId: string): Observable { @@ -93,8 +95,8 @@ export class Gb3ShareLinkService extends Gb3ApiService { attributeFilters: content.attributeFilters, timeExtent: content.timeExtent ? { - start: TimeExtentUtils.parseDefaultUTCDate(content.timeExtent.start), - end: TimeExtentUtils.parseDefaultUTCDate(content.timeExtent.end), + start: this.timeService.createUTCDateFromString(content.timeExtent.start), + end: this.timeService.createUTCDateFromString(content.timeExtent.end), } : undefined, }; diff --git a/src/app/shared/services/apis/gb3/gb3-topics.service.spec.ts b/src/app/shared/services/apis/gb3/gb3-topics.service.spec.ts index 2b48b04d7..a3e79d833 100644 --- a/src/app/shared/services/apis/gb3/gb3-topics.service.spec.ts +++ b/src/app/shared/services/apis/gb3/gb3-topics.service.spec.ts @@ -171,6 +171,10 @@ describe('Gb3TopicsService', () => { alwaysMaxRange: false, range: undefined, }, + initialTimeSliderExtent: { + start: new Date('1850'), + end: new Date('2020'), + }, filterConfigurations: [ { name: 'Anzeigeoptionen nach Hauptnutzung', diff --git a/src/app/shared/services/apis/gb3/gb3-topics.service.ts b/src/app/shared/services/apis/gb3/gb3-topics.service.ts index aca5ec875..5eb5a52b7 100644 --- a/src/app/shared/services/apis/gb3/gb3-topics.service.ts +++ b/src/app/shared/services/apis/gb3/gb3-topics.service.ts @@ -1,4 +1,4 @@ -import {Injectable} from '@angular/core'; +import {Inject, Injectable} from '@angular/core'; import {forkJoin, Observable} from 'rxjs'; import {map} from 'rxjs/operators'; import {DataCataloguePage} from '../../../enums/data-catalogue-page.enum'; @@ -10,9 +10,11 @@ import { FilterValue, Map, MapLayer, + TimeSliderConfiguration, TimeSliderLayer, TimeSliderLayerSource, TimeSliderParameterSource, + TimeSliderSettings, TimeSliderSourceType, TopicsResponse, WmsFilterValue, @@ -31,6 +33,11 @@ import {QueryTopic} from '../../../interfaces/query-topic.interface'; import {ApiGeojsonGeometryToGb3ConverterUtils} from '../../../utils/api-geojson-geometry-to-gb3-converter.utils'; import {LinkObject} from '../../../interfaces/link-object.interface'; import {GeometryWithSrs} from '../../../interfaces/geojson-types-with-srs.interface'; +import {HttpClient} from '@angular/common/http'; +import {ConfigService} from '../../config.service'; +import {TIME_SERVICE} from '../../../../app.module'; +import {TimeService} from '../../../interfaces/time-service.interface'; +import {TimeSliderService} from '../../../../map/services/time-slider.service'; const INACTIVE_STRING_FILTER_VALUE = ''; const INACTIVE_NUMBER_FILTER_VALUE = -1; @@ -44,6 +51,15 @@ export class Gb3TopicsService extends Gb3ApiService { private readonly dataDatasetTabUrl = `/${MainPage.Data}/${DataCataloguePage.Datasets}`; private readonly dataMapTabUrl = `/${MainPage.Data}/${DataCataloguePage.Maps}`; + constructor( + httpClient: HttpClient, + configService: ConfigService, + @Inject(TIME_SERVICE) timeService: TimeService, + private readonly timeSliderService: TimeSliderService, + ) { + super(httpClient, configService, timeService); + } + public loadTopics(): Observable { const requestUrl = this.createTopicsUrl(); const topicsListData = this.get(requestUrl); @@ -187,23 +203,7 @@ export class Gb3TopicsService extends Gb3ApiService { ) .reverse(), // reverse the order of the layers because the order in the GB3 interfaces (Topic, ActiveMapItem) is inverted // to the order of the WMS specifications - timeSliderConfiguration: topic.timesliderConfiguration - ? { - name: topic.timesliderConfiguration.name, - alwaysMaxRange: topic.timesliderConfiguration.alwaysMaxRange, - dateFormat: topic.timesliderConfiguration.dateFormat, - description: topic.timesliderConfiguration.description, - maximumDate: topic.timesliderConfiguration.maximumDate, - minimumDate: topic.timesliderConfiguration.minimumDate, - minimalRange: topic.timesliderConfiguration.minimalRange, - range: topic.timesliderConfiguration.range, - sourceType: topic.timesliderConfiguration.sourceType as TimeSliderSourceType, - source: this.transformTimeSliderConfigurationSource( - topic.timesliderConfiguration.source, - topic.timesliderConfiguration.sourceType, - ), - } - : undefined, + ...this.handleTimeSliderConfiguration(topic.timesliderConfiguration), filterConfigurations: topic.filterConfigurations?.map((filterConfiguration): FilterConfiguration => { return { name: filterConfiguration.name, @@ -234,6 +234,33 @@ export class Gb3TopicsService extends Gb3ApiService { return topicsResponse; } + private handleTimeSliderConfiguration( + timesliderConfiguration: TopicsListData['categories'][0]['topics'][0]['timesliderConfiguration'] | undefined, + ): TimeSliderSettings { + if (!timesliderConfiguration) { + return { + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, + }; + } + const config: TimeSliderConfiguration = { + name: timesliderConfiguration.name, + alwaysMaxRange: timesliderConfiguration.alwaysMaxRange, + dateFormat: timesliderConfiguration.dateFormat, + description: timesliderConfiguration.description, + maximumDate: timesliderConfiguration.maximumDate, + minimumDate: timesliderConfiguration.minimumDate, + minimalRange: timesliderConfiguration.minimalRange, + range: timesliderConfiguration.range, + sourceType: timesliderConfiguration.sourceType as TimeSliderSourceType, + source: this.transformTimeSliderConfigurationSource(timesliderConfiguration.source, timesliderConfiguration.sourceType), + }; + return { + timeSliderConfiguration: config, + initialTimeSliderExtent: this.timeSliderService.createInitialTimeSliderExtent(config), + }; + } + private transformTimeSliderConfigurationSource( // the following typing for `source` is used to extract a subtype of the generated interface `TopicsListData` source: TopicsListData['categories'][0]['topics'][0]['timesliderConfiguration']['source'], diff --git a/src/app/shared/services/apis/grav-cms/grav-cms.service.ts b/src/app/shared/services/apis/grav-cms/grav-cms.service.ts index fe22a6822..93bada27a 100644 --- a/src/app/shared/services/apis/grav-cms/grav-cms.service.ts +++ b/src/app/shared/services/apis/grav-cms/grav-cms.service.ts @@ -1,16 +1,17 @@ -import {Injectable} from '@angular/core'; +import {Inject, Injectable} from '@angular/core'; import {BaseApiService} from '../abstract-api.service'; import {Observable} from 'rxjs'; import {DiscoverMapsItem} from '../../../interfaces/discover-maps-item.interface'; import {map} from 'rxjs/operators'; import {DiscoverMapsRoot, FrequentlyUsedRoot, PageInfosRoot, Pages} from '../../../models/grav-cms-generated.interfaces'; import {PageNotification, PageNotificationSeverity} from '../../../interfaces/page-notification.interface'; -import dayjs from 'dayjs'; import {MainPage} from '../../../enums/main-page.enum'; import {FrequentlyUsedItem} from '../../../interfaces/frequently-used-item.interface'; -import customParseFormat from 'dayjs/plugin/customParseFormat'; +import {HttpClient} from '@angular/common/http'; +import {ConfigService} from '../../config.service'; +import {TimeService} from '../../../interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../../../app.module'; -dayjs.extend(customParseFormat); const DATE_FORMAT = 'DD.MM.YYYY'; @Injectable({ @@ -22,6 +23,9 @@ export class GravCmsService extends BaseApiService { private readonly pageInfosEndpoint: string = 'pageinfos.json'; private readonly frequentlyUsedItemsEndpoint: string = 'frequentlyused.json'; + constructor(httpClient: HttpClient, configService: ConfigService, @Inject(TIME_SERVICE) timeService: TimeService) { + super(httpClient, configService, timeService); + } public loadDiscoverMapsData(): Observable { const requestUrl = this.createFullEndpointUrl(this.discoverMapsEndpoint); return this.get(requestUrl).pipe(map((response) => this.transformDiscoverMapsData(response))); @@ -44,8 +48,8 @@ export class GravCmsService extends BaseApiService { title: discoverMapData.title, description: discoverMapData.description, mapId: discoverMapData.id, - fromDate: dayjs(discoverMapData.from_date, DATE_FORMAT).toDate(), - toDate: dayjs(discoverMapData.to_date, DATE_FORMAT).toDate(), + fromDate: this.timeService.createDateFromString(discoverMapData.from_date, DATE_FORMAT), + toDate: this.timeService.createDateFromString(discoverMapData.to_date, DATE_FORMAT), image: { url: this.createFullImageUrl(discoverMapData.image.path), name: discoverMapData.image.name, @@ -65,8 +69,8 @@ export class GravCmsService extends BaseApiService { title: pageInfoData.title, description: pageInfoData.description, pages: this.transformPagesToMainPages(pageInfoData.pages), - fromDate: dayjs(pageInfoData.from_date, DATE_FORMAT).toDate(), - toDate: dayjs(pageInfoData.to_date, DATE_FORMAT).toDate(), + fromDate: this.timeService.createDateFromString(pageInfoData.from_date, DATE_FORMAT), + toDate: this.timeService.createDateFromString(pageInfoData.to_date, DATE_FORMAT), severity: pageInfoData.severity as PageNotificationSeverity, isMarkedAsRead: false, }; @@ -90,7 +94,7 @@ export class GravCmsService extends BaseApiService { altText: frequentlyUsedData.image_alt, } : undefined, - created: dayjs.unix(+frequentlyUsedData.created).toDate(), + created: this.timeService.createDateFromUnixTimestamp(Number(frequentlyUsedData.created)), }; }); } diff --git a/src/app/shared/services/dayjs.service.spec.ts b/src/app/shared/services/dayjs.service.spec.ts new file mode 100644 index 000000000..b3a10a8cc --- /dev/null +++ b/src/app/shared/services/dayjs.service.spec.ts @@ -0,0 +1,92 @@ +import {TestBed} from '@angular/core/testing'; +import {TIME_SERVICE} from '../../app.module'; +import {DayjsService} from './dayjs.service'; + +describe('DayjsService', () => { + let dayjsService: DayjsService; + beforeEach(() => { + TestBed.configureTestingModule({providers: [{provide: TIME_SERVICE, useClass: DayjsService}]}); + dayjsService = TestBed.inject(TIME_SERVICE) as DayjsService; + }); + + it('should be created', () => { + expect(dayjsService).toBeTruthy(); + }); + + describe('getDateFromString', () => { + it('returns the date object from a string', () => { + expect(dayjsService.createDateFromString('2023-10-01')).toEqual(new Date(2023, 9, 1)); + }); + + it('returns the date object from a string with a format', () => { + expect(dayjsService.createDateFromString('2023-10-01', 'YYYY-MM-DD')).toEqual(new Date(2023, 9, 1)); + }); + }); + + describe('getDateAsFormattedString', () => { + it('returns the date as a formatted string', () => { + const date = new Date(2023, 9, 1); // October 1, 2023 + expect(dayjsService.getDateAsFormattedString(date, 'YYYY-MM-DD')).toBe('2023-10-01'); + }); + }); + + describe('getDateAsUTCString', () => { + it('returns the UTC date as a formatted string', () => { + const date = new Date(Date.UTC(2023, 9, 1)); // October 1, 2023 UTC + expect(dayjsService.getDateAsUTCString(date, 'YYYY-MM-DD')).toBe('2023-10-01'); + }); + }); + + describe('getUnixDate', () => { + it('returns the date object from a Unix timestamp', () => { + const expectedDate = new Date(Date.UTC(2000, 0, 1)); + // 946684800 is the Unix timestamp for 2000-01-01T00:00:00.000Z (from https://timestampgenerator.com/946684800/+00:00) + expect(dayjsService.createDateFromUnixTimestamp(946684800).getTime()).toEqual(expectedDate.getTime()); + }); + }); + + describe('getUTCDateFromString', () => { + it('parses the UTC date from a string', () => { + expect(dayjsService.createUTCDateFromString('2023-10-01', 'YYYY-MM-DD')).toEqual(new Date(Date.UTC(2023, 9, 1))); + expect(dayjsService.createUTCDateFromString('2023-10-01')).toEqual(new Date(Date.UTC(2023, 9, 1))); + }); + }); + + describe('isDate', () => { + it('validates the date string', () => { + expect(dayjsService.isDate('2023-10-01')).toBe(true); + expect(dayjsService.isDate('invalid-date')).toBe(false); + }); + }); + + describe('calculateDifferenceBetweenDates', () => { + it('calculates the difference between two dates', () => { + const date1 = new Date(2023, 9, 1); + const date2 = new Date(2023, 9, 2); + expect(dayjsService.calculateDifferenceBetweenDates(date1, date2)).toBe(86400000); // 1 day in milliseconds + }); + }); + + describe('addDuration', () => { + it('adds the duration to the date', () => { + const date = new Date(2023, 9, 1); + const range = 'P1D'; + expect(dayjsService.addRangeToDate(date, range)).toEqual(new Date(2023, 9, 2)); + }); + }); + + describe('subtractDuration', () => { + it('subtracts the duration from the date', () => { + const date = new Date(2023, 9, 1); + const range = 'P1D'; + expect(dayjsService.subtractRangeFromDate(date, range)).toEqual(new Date(2023, 8, 30)); + }); + }); + + describe('getPartial', () => { + it('returns the correct partial value', () => { + expect(dayjsService.createPartialFromString('2023-10-01', 'years')).toBe(2023); + expect(dayjsService.createPartialFromString('2023-10-01', 'months')).toBe(9); // month is 0-indexed + }); + }); +}); diff --git a/src/app/shared/services/dayjs.service.ts b/src/app/shared/services/dayjs.service.ts new file mode 100644 index 000000000..a6ff9f0cc --- /dev/null +++ b/src/app/shared/services/dayjs.service.ts @@ -0,0 +1,164 @@ +import {Injectable} from '@angular/core'; +import {TimeService} from '../interfaces/time-service.interface'; +import dayjs, {ManipulateType} from 'dayjs'; +import durationPlugin, {Duration} from 'dayjs/plugin/duration'; +import utc from 'dayjs/plugin/utc'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import {DateUnit} from '../types/date-unit.type'; + +dayjs.extend(durationPlugin); +dayjs.extend(customParseFormat); +dayjs.extend(utc); + +@Injectable({ + providedIn: 'root', +}) +export class DayjsService implements TimeService { + public createDateFromString(date: string, format?: string): Date { + return this.createDayjsObject(date, format).toDate(); + } + + public getDateAsFormattedString(date: Date, format: string): string { + return this.createDayjsObject(date).format(format); + } + + public getDateAsUTCString(date: Date, format?: string): string { + return this.createUTCDayjsObject(date).format(format); + } + + public createPartialFromString(date: string, unit: DateUnit): number { + return this.createDayjsObject(date).get(unit); + } + + public createDateFromUnixTimestamp(timestamp: number): Date { + return dayjs.unix(timestamp).toDate(); + } + + public createUTCDateFromString(date: string, format?: string): Date { + return this.createUTCDayjsObject(date, format).toDate(); + } + + public isDate(value: string): boolean { + return this.createDayjsObject(value).isValid(); + } + + public calculateDifferenceBetweenDates(firstDate: Date, secondDate: Date): number { + return Math.abs(this.createDayjsObject(secondDate).diff(this.createDayjsObject(firstDate))); + } + + public getISORangeInMilliseconds(range: string): number { + return this.createDayjsDurationFromISOString(range).asMilliseconds(); + } + + public addMinimalRangeToDate(date: Date, unit: string): Date { + return this.createDayjsObject(date) + .add(1, unit as ManipulateType) + .toDate(); + } + + public isStringSingleTimeUnitRange(range: string): boolean { + const rangeDuration = this.createDayjsDurationFromISOString(range); + const unit = this.extractUniqueUnitFromDuration(rangeDuration); + return unit !== undefined && this.getDurationAsNumber(rangeDuration, unit) === 1; + } + + public addRangeToDate(date: Date, range: string): Date { + const duration = this.createDayjsDurationFromISOString(range); + const unit = this.extractUniqueUnitFromDuration(duration); + if (!unit) { + return this.createDayjsObject(date).add(duration).toDate(); + } + const value = this.getDurationAsNumber(duration, unit); + return this.createDayjsObject(date).add(this.createDayjsDurationFromNumber(value, unit)).toDate(); + } + + public subtractRangeFromDate(date: Date, range: string): Date { + const duration = this.createDayjsDurationFromISOString(range); + const unit = this.extractUniqueUnitFromDuration(duration); + if (!unit) { + return this.createDayjsObject(date).subtract(duration).toDate(); + } + const value = this.getDurationAsNumber(duration, unit); + return this.createDayjsObject(date).subtract(this.createDayjsDurationFromNumber(value, unit)).toDate(); + } + + private createDayjsDurationFromNumber(value: number, unit: ManipulateType): Duration { + return dayjs.duration(value, unit); + } + + private createDayjsDurationFromISOString(range: string): Duration { + return dayjs.duration(range); + } + + /** + * Extracts the unit from the given duration or if it contains values with multiple units. + * + * @remarks + * It does return a unit ('years'/'months'/...) only if the given duration contains values of this unit and nothing else; + * otherwise. + * + * @example + * 'P3Y' is a duration of 3 years. The duration only contains years and therefore this method returns 'years' + * 'P1Y6M' is a duration of 1 year and 6 months. It contains years (1) and months (6) which is a mix of two units. The return value will + * be . + * */ + private extractUniqueUnitFromDuration(duration: Duration): ManipulateType | undefined { + if (duration.years() === duration.asYears()) return 'years'; + if (duration.months() === duration.asMonths()) return 'months'; + if (duration.days() === duration.asDays()) return 'days'; + if (duration.hours() === duration.asHours()) return 'hours'; + if (duration.minutes() === duration.asMinutes()) return 'minutes'; + if (duration.seconds() === duration.asSeconds()) return 'seconds'; + if (duration.milliseconds() === duration.asMilliseconds()) return 'milliseconds'; + return undefined; + } + + /** + * Gets the whole given duration as a number value in the desired unit. + */ + private getDurationAsNumber(duration: Duration, unit: ManipulateType): number { + switch (unit) { + case 'ms': + case 'millisecond': + case 'milliseconds': + return duration.asMilliseconds(); + case 'second': + case 'seconds': + case 's': + return duration.asSeconds(); + case 'minute': + case 'minutes': + case 'm': + return duration.asMinutes(); + case 'hour': + case 'hours': + case 'h': + return duration.asHours(); + case 'd': + case 'D': + case 'day': + case 'days': + return duration.asDays(); + case 'M': + case 'month': + case 'months': + return duration.asMonths(); + case 'y': + case 'year': + case 'years': + return duration.asYears(); + case 'w': + case 'week': + case 'weeks': + return duration.asWeeks(); + } + } + + private createDayjsObject(date: Date | string, format?: string): dayjs.Dayjs { + return dayjs(date, format); + } + + private createUTCDayjsObject(date: Date | string, format?: string): dayjs.Dayjs { + return dayjs.utc(date, format); + } +} diff --git a/src/app/shared/services/local-storage.service.ts b/src/app/shared/services/local-storage.service.ts index 4f9bc169f..c0bef251d 100644 --- a/src/app/shared/services/local-storage.service.ts +++ b/src/app/shared/services/local-storage.service.ts @@ -1,10 +1,17 @@ -import {Injectable} from '@angular/core'; +import {Inject, Injectable} from '@angular/core'; import {LocalStorageKey} from '../types/local-storage-key.type'; +import {AbstractStorageService} from './abstract-storage.service'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../interfaces/time-service.interface'; @Injectable({ providedIn: 'root', }) -export class LocalStorageService { +export class LocalStorageService extends AbstractStorageService { + constructor(@Inject(TIME_SERVICE) timeService: TimeService) { + super(timeService); + } + public set(key: LocalStorageKey, value: string) { localStorage.setItem(key, value); } @@ -12,4 +19,8 @@ export class LocalStorageService { public get(key: LocalStorageKey): string | null { return localStorage.getItem(key); } + + public remove(key: LocalStorageKey) { + localStorage.removeItem(key); + } } diff --git a/src/app/shared/services/session-storage.service.ts b/src/app/shared/services/session-storage.service.ts index 77618e08b..c99b83507 100644 --- a/src/app/shared/services/session-storage.service.ts +++ b/src/app/shared/services/session-storage.service.ts @@ -1,10 +1,17 @@ -import {Injectable} from '@angular/core'; +import {Inject, Injectable} from '@angular/core'; import {SessionStorageKey} from '../types/session-storage-key.type'; +import {AbstractStorageService} from './abstract-storage.service'; +import {TIME_SERVICE} from '../../app.module'; +import {TimeService} from '../interfaces/time-service.interface'; @Injectable({ providedIn: 'root', }) -export class SessionStorageService { +export class SessionStorageService extends AbstractStorageService { + constructor(@Inject(TIME_SERVICE) timeService: TimeService) { + super(timeService); + } + public set(key: SessionStorageKey, value: string) { sessionStorage.setItem(key, value); } diff --git a/src/app/shared/types/date-unit.type.ts b/src/app/shared/types/date-unit.type.ts new file mode 100644 index 000000000..be35cb5f9 --- /dev/null +++ b/src/app/shared/types/date-unit.type.ts @@ -0,0 +1 @@ +export type DateUnit = 'days' | 'months' | 'years' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'; diff --git a/src/app/shared/utils/storage.utils.spec.ts b/src/app/shared/utils/storage.utils.spec.ts deleted file mode 100644 index 54d2d9800..000000000 --- a/src/app/shared/utils/storage.utils.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {StorageUtils} from './storage.utils'; -import {TimeExtentUtils} from './time-extent.utils'; - -describe('StorageUtils', () => { - describe('parseJson', () => { - it(`parses a Json with valid Dates correctly`, () => { - const stringToParse: string = - '{"date":"1506-01-01T00:00:00.000Z", "number": 2683132, "string": "test", "stringifiedNumberParseableAsDate": "12"}'; - - const expectedJsonObject = { - date: TimeExtentUtils.parseDefaultUTCDate('1506-01-01T00:00:00.000Z'), - number: 2683132, - string: 'test', - stringifiedNumberParseableAsDate: '12', - }; - expect(StorageUtils.parseJson(stringToParse)).toEqual(expectedJsonObject); - }); - it(`does not parses an invalid Date-String `, () => { - const stringToParse: string = '{"invalidDate":"T1506-01-01T00:00:00.000Z"}'; - - const expectedJsonObject = {invalidDate: 'T1506-01-01T00:00:00.000Z'}; - expect(StorageUtils.parseJson(stringToParse)).toEqual(expectedJsonObject); - }); - it(`does not parses a number as Date`, () => { - const stringToParse: string = '{"validDateAsNumber":19000101}'; - - const expectedJsonObject = {validDateAsNumber: 19000101}; - expect(StorageUtils.parseJson(stringToParse)).toEqual(expectedJsonObject); - }); - }); -}); diff --git a/src/app/shared/utils/storage.utils.ts b/src/app/shared/utils/storage.utils.ts deleted file mode 100644 index 26a5cd6e3..000000000 --- a/src/app/shared/utils/storage.utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import dayjs from 'dayjs'; -import {TimeExtentUtils} from './time-extent.utils'; - -export class StorageUtils { - /** - * Returns a date in the correct format after parsing from stringified Object. This is used within the JSON.parse method, hence the - * "any" type for the value parameter. - * - * Note that we're not using the "strict" mode of "dayjs.isValid" here, because we parse a stringified date that was created by using - * JSON.stringify(). This uses the ISO8601 format, which looks like YYYY-MM-DDTHH:mm:ss.SSSZ. The strict mode for dayjs() does not work - * properly with the "Z" parameter (see e.g. https://github.com/iamkun/dayjs/issues/1729), although it theoretically should. Instead of - * hacking this and replacing the "Z" with an empty string, we just use the default mode of dayjs() here. However, this has the issue - * that e.g. "12" is valid and parsed as a date. Therefore, we check whether the parsed string's ISO string representation is equal to - * the original string. If it is, we return the parsed date, otherwise the original string (see GB3-1597). - */ - private static reviver(key: string, value: any): any { - if (typeof value === 'string' && dayjs(value).isValid()) { - const parsed = TimeExtentUtils.parseDefaultUTCDate(value); - return parsed.toISOString() === value ? parsed : value; - } - return value; - } - - public static parseJson(value: string): T { - return JSON.parse(value, StorageUtils.reviver); - } - - public static stringifyJson(value: T): string { - return JSON.stringify(value); - } -} diff --git a/src/app/shared/utils/time-extent.utils.ts b/src/app/shared/utils/time-extent.utils.ts deleted file mode 100644 index b643e5d67..000000000 --- a/src/app/shared/utils/time-extent.utils.ts +++ /dev/null @@ -1,188 +0,0 @@ -import {Duration} from 'dayjs/plugin/duration'; -import utc from 'dayjs/plugin/utc'; -import dayjs, {ManipulateType} from 'dayjs'; -import {TimeSliderConfiguration} from '../interfaces/topic.interface'; -import {TimeExtent} from '../../map/interfaces/time-extent.interface'; - -dayjs.extend(utc); - -export class TimeExtentUtils { - /** - * Creates an initial time extent based on the given time slider configuration. - */ - public static createInitialTimeSliderExtent(timeSliderConfig: TimeSliderConfiguration): TimeExtent { - const minimumDate: Date = TimeExtentUtils.parseUTCDate(timeSliderConfig.minimumDate, timeSliderConfig.dateFormat); - const maximumDate: Date = TimeExtentUtils.parseUTCDate(timeSliderConfig.maximumDate, timeSliderConfig.dateFormat); - const range: Duration | null = timeSliderConfig.range ? dayjs.duration(timeSliderConfig.range) : null; - return { - start: minimumDate, - end: range ? TimeExtentUtils.addDuration(minimumDate, range) : maximumDate, - }; - } - - public static parseUTCDate(date: string, format: string): Date { - return dayjs.utc(date, format).toDate(); - } - - public static parseDefaultUTCDate(date: string): Date { - return dayjs.utc(date).toDate(); - } - - /** - * Extracts the unit from the given duration or if it contains values with multiple units. - * - * @remarks - * It does return a unit ('years'/'months'/...) only if the given duration contains values of this unit and nothing else; - * otherwise. - * - * @example - * 'P3Y' is a duration of 3 years. The duration only contains years and therefore this method returns 'years' - * 'P1Y6M' is a duration of 1 year and 6 months. It contains years (1) and months (6) which is a mix of two units. The return value will - * be . - * */ - public static extractUniqueUnitFromDuration(duration: Duration): ManipulateType | undefined { - if (duration.years() === duration.asYears()) return 'years'; - if (duration.months() === duration.asMonths()) return 'months'; - if (duration.days() === duration.asDays()) return 'days'; - if (duration.hours() === duration.asHours()) return 'hours'; - if (duration.minutes() === duration.asMinutes()) return 'minutes'; - if (duration.seconds() === duration.asSeconds()) return 'seconds'; - if (duration.milliseconds() === duration.asMilliseconds()) return 'milliseconds'; - return undefined; - } - - /** - * Extracts a unit from the given date format (ISO8601) if it contains exactly one or if it contains multiple units. - * - * @remarks - * It does return a unit ('years'/'months'/...) only if the given duration contains values of this unit and nothing else; - * otherwise. - * - * @example - * 'YYYY' is a date format containing only years; The unique unit is years and therefore this method returns 'years' - * 'H:m s.SSS' is a date format containing hours, minutes, seconds and milliseconds; there are multiple units therefore this method - * returns 'undefined' - * */ - public static extractUniqueUnitFromDateFormat(dateFormat: string): ManipulateType | undefined { - if (dateFormat.replace(/S/g, '').trim() === '') return 'milliseconds'; - if (dateFormat.replace(/s/g, '').trim() === '') return 'seconds'; - if (dateFormat.replace(/m/g, '').trim() === '') return 'minutes'; - if (dateFormat.replace(/[hH]/g, '').trim() === '') return 'hours'; - if (dateFormat.replace(/[dD]/g, '').trim() === '') return 'days'; - if (dateFormat.replace(/M/g, '').trim() === '') return 'months'; - if (dateFormat.replace(/Y/g, '').trim() === '') return 'years'; - return undefined; - } - - /** - * Extracts the smallest unit from the given date format (ISO8601) or if nothing matches. - * - * @example - * 'YYYY-MM' is a date format containing years and months; The smallest unit is months (months < years) and therefore this method returns - * 'months' - * 'H:m s.SSS' is a date format containing hours, minutes, seconds and milliseconds; The smallest unit is milliseconds and therefore this - * method returns 'milliseconds' - * */ - public static extractSmallestUnitFromDateFormat(dateFormat: string): ManipulateType | undefined { - if (dateFormat.includes('SSS')) return 'milliseconds'; - if (dateFormat.includes('s')) return 'seconds'; - if (dateFormat.includes('m')) return 'minutes'; - if (dateFormat.toLowerCase().includes('h')) return 'hours'; // both `h` and `H` are used for `hours` - if (dateFormat.toLowerCase().includes('d')) return 'days'; // both `d` and `D` are used for `days` - if (dateFormat.includes('M')) return 'months'; - if (dateFormat.includes('Y')) return 'years'; - return undefined; - } - - /** - * Adds the duration to the given date as exact as possible. - * - * @remarks - * It does more than a simple `dayjs(date).add(duration)`. It will add values of a specific unit to the date in case that - * the duration contains only values of one specific unit (e.g. 'years'). This has the advantage that it does not use - * a generic solution which would be 365 days in case of a year. - * - * @example - * addDuration(01.01.2000, duration(1, 'years')) === 01.01.2001 - * while the default way using `dayjs.add` would lead to an error: dayjs(01.01.2000).add(duration(1, 'years')) === 01.01.2000 + 365 days - * === 31.12.2000 - * */ - public static addDuration(date: Date, duration: Duration): Date { - const unit = TimeExtentUtils.extractUniqueUnitFromDuration(duration); - if (!unit) { - return dayjs(date).add(duration).toDate(); - } - const value = TimeExtentUtils.getDurationAsNumber(duration, unit); - return dayjs(date).add(value, unit).toDate(); - } - - /** - * Subtracts the duration from the given date as exact as possible. - * - * @remarks - * It does more than a simple `dayjs(date).subtract(duration)`. It will subtract values of a specific unit from the date in case that - * the duration contains only values of one specific unit (e.g. 'years'). This has the advantage that it does not use - * a generic solution which would be 365 days in case of a year. - * - * @example - * subtractDuration(01.01.2001, duration(1, 'years')) === 01.01.2000 - * while the default way using `dayjs.subtract` would lead to an error: dayjs(01.01.2001).subtract(duration(1, 'years')) === 01.01.2001 - - * 365 days === 02.01.2000 - * */ - public static subtractDuration(date: Date, duration: Duration): Date { - const unit = TimeExtentUtils.extractUniqueUnitFromDuration(duration); - if (!unit) { - return dayjs(date).subtract(duration).toDate(); - } - const value = TimeExtentUtils.getDurationAsNumber(duration, unit); - return dayjs(date).subtract(value, unit).toDate(); - } - - /** - * Gets the whole given duration as a number value in the desired unit. - */ - public static getDurationAsNumber(duration: Duration, unit: ManipulateType): number { - switch (unit) { - case 'ms': - case 'millisecond': - case 'milliseconds': - return duration.asMilliseconds(); - case 'second': - case 'seconds': - case 's': - return duration.asSeconds(); - case 'minute': - case 'minutes': - case 'm': - return duration.asMinutes(); - case 'hour': - case 'hours': - case 'h': - return duration.asHours(); - case 'd': - case 'D': - case 'day': - case 'days': - return duration.asDays(); - case 'M': - case 'month': - case 'months': - return duration.asMonths(); - case 'y': - case 'year': - case 'years': - return duration.asYears(); - case 'w': - case 'week': - case 'weeks': - return duration.asWeeks(); - } - } - - /** - * Returns the difference in milliseconds between the two given dates. - */ - public static calculateDifferenceBetweenDates(firstDate: Date, secondDate: Date): number { - return Math.abs(dayjs(firstDate).diff(secondDate)); - } -} diff --git a/src/app/state/app/selectors/search-results.selector.spec.ts b/src/app/state/app/selectors/search-results.selector.spec.ts index f71961744..86d741d40 100644 --- a/src/app/state/app/selectors/search-results.selector.spec.ts +++ b/src/app/state/app/selectors/search-results.selector.spec.ts @@ -210,6 +210,8 @@ describe('search-result selector', () => { layers: [], printTitle: '', opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, }, { // matching by title @@ -226,6 +228,8 @@ describe('search-result selector', () => { layers: [], printTitle: '', opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, }, { // not matching @@ -242,6 +246,8 @@ describe('search-result selector', () => { layers: [], printTitle: '', opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, }, { // matching by title and keywords @@ -258,6 +264,8 @@ describe('search-result selector', () => { layers: [], printTitle: '', opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, }, { // partially matching by title @@ -274,6 +282,8 @@ describe('search-result selector', () => { layers: [], printTitle: '', opacity: 1, + timeSliderConfiguration: undefined, + initialTimeSliderExtent: undefined, }, ]; } diff --git a/src/app/state/auth/effects/auth-status.effects.spec.ts b/src/app/state/auth/effects/auth-status.effects.spec.ts index a2478b255..3eeb10775 100644 --- a/src/app/state/auth/effects/auth-status.effects.spec.ts +++ b/src/app/state/auth/effects/auth-status.effects.spec.ts @@ -15,7 +15,7 @@ import {selectActiveMapItemConfigurations} from '../../map/selectors/active-map- import {selectMaps} from '../../map/selectors/maps.selector'; import {selectFavouriteBaseConfig} from '../../map/selectors/favourite-base-config.selector'; import {selectUserDrawingsVectorLayers} from '../../map/selectors/user-drawings-vector-layers.selector'; -import {MAP_SERVICE} from '../../../app.module'; +import {MAP_SERVICE, TIME_SERVICE} from '../../../app.module'; import {MapServiceStub} from '../../../testing/map-testing/map.service.stub'; import {LayerCatalogActions} from '../../map/actions/layer-catalog.actions'; import {Gb3ShareLinkService} from '../../../shared/services/apis/gb3/gb3-share-link.service'; @@ -29,6 +29,7 @@ import {selectItems} from '../../map/selectors/active-map-items.selector'; import {selectDrawings} from '../../map/reducers/drawing.reducer'; import {ToolService} from '../../../map/interfaces/tool.service'; import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import {TimeService} from '../../../shared/interfaces/time-service.interface'; const mockOAuthService = jasmine.createSpyObj({ logout: void 0, @@ -40,6 +41,7 @@ describe('AuthStatusEffects', () => { let store: MockStore; let effects: AuthStatusEffects; let storageService: SessionStorageService; + let timeService: TimeService; beforeEach(() => { actions$ = new Observable(); @@ -56,6 +58,7 @@ describe('AuthStatusEffects', () => { provideHttpClientTesting(), ], }); + timeService = TestBed.inject(TIME_SERVICE); storageService = TestBed.inject(SessionStorageService); store = TestBed.inject(MockStore); store.overrideSelector(selectActiveMapItemConfigurations, []); @@ -74,7 +77,10 @@ describe('AuthStatusEffects', () => { describe('login$', () => { it('logins using the AuthService and stores the current map state into a share link item; dispatches no further actions', (done: DoneFn) => { - const shareLinkItem: ShareLinkItem = ShareLinkItemTestUtils.createShareLinkItem(); + const shareLinkItem: ShareLinkItem = ShareLinkItemTestUtils.createShareLinkItem( + timeService.createUTCDateFromString('1000'), + timeService.createUTCDateFromString('2020'), + ); const storageServiceSpy = spyOn(storageService, 'set'); store.overrideSelector(selectCurrentShareLinkItem, shareLinkItem); @@ -93,7 +99,10 @@ describe('AuthStatusEffects', () => { describe('logout$', () => { it('logouts using the AuthService and stores the current map state into a share link item; dispatches no further actions', (done: DoneFn) => { - const shareLinkItem: ShareLinkItem = ShareLinkItemTestUtils.createShareLinkItem(); + const shareLinkItem: ShareLinkItem = ShareLinkItemTestUtils.createShareLinkItem( + timeService.createUTCDateFromString('1000'), + timeService.createUTCDateFromString('2020'), + ); const storageServiceSpy = spyOn(storageService, 'set'); store.overrideSelector(selectCurrentShareLinkItem, shareLinkItem); const isForced = true; @@ -116,7 +125,10 @@ describe('AuthStatusEffects', () => { 'dispatches AuthStatusActions.completeRestoreApplication after setting the layer catalog' + 'and loading an existing share link item from the session storage.', (done: DoneFn) => { - const shareLinkItem: ShareLinkItem = ShareLinkItemTestUtils.createShareLinkItem(); + const shareLinkItem: ShareLinkItem = ShareLinkItemTestUtils.createShareLinkItem( + timeService.createUTCDateFromString('1000'), + timeService.createUTCDateFromString('2020'), + ); const shareLinkItemString = JSON.stringify(shareLinkItem); const storageServiceGetSpy = spyOn(storageService, 'get').and.returnValue(shareLinkItemString); const storageServiceRemoveSpy = spyOn(storageService, 'remove').and.stub(); diff --git a/src/app/state/auth/effects/auth-status.effects.ts b/src/app/state/auth/effects/auth-status.effects.ts index ba2e191a7..328b394c9 100644 --- a/src/app/state/auth/effects/auth-status.effects.ts +++ b/src/app/state/auth/effects/auth-status.effects.ts @@ -19,7 +19,6 @@ import {DrawingActiveMapItem} from '../../../map/models/implementations/drawing. import {MAP_SERVICE} from '../../../app.module'; import {MapService} from '../../../map/interfaces/map.service'; import {isActiveMapItemOfType} from '../../../shared/type-guards/active-map-item-type.type-guard'; -import {StorageUtils} from '../../../shared/utils/storage.utils'; import {defaultActiveMapItemConfiguration} from '../../../shared/interfaces/active-map-item-configuration.interface'; @Injectable() @@ -30,7 +29,7 @@ export class AuthStatusEffects { ofType(AuthStatusActions.performLogin), concatLatestFrom(() => this.store.select(selectCurrentShareLinkItem)), tap(([_, shareLinkItem]) => { - this.sessionStorageService.set('shareLinkItem', StorageUtils.stringifyJson(shareLinkItem)); + this.sessionStorageService.set('shareLinkItem', this.sessionStorageService.stringifyJson(shareLinkItem)); this.authService.login(); }), ); @@ -44,7 +43,7 @@ export class AuthStatusEffects { ofType(AuthStatusActions.performLogout), concatLatestFrom(() => this.store.select(selectCurrentShareLinkItem)), tap(([{isForced}, shareLinkItem]) => { - this.sessionStorageService.set('shareLinkItem', StorageUtils.stringifyJson(shareLinkItem)); + this.sessionStorageService.set('shareLinkItem', this.sessionStorageService.stringifyJson(shareLinkItem)); this.authService.logout(isForced); }), ); @@ -61,7 +60,7 @@ export class AuthStatusEffects { this.sessionStorageService.remove('shareLinkItem'); const shareLinkItem: ShareLinkItem | undefined = shareLinkItemString - ? StorageUtils.parseJson(shareLinkItemString) + ? this.sessionStorageService.parseJson(shareLinkItemString) : undefined; return shareLinkItem diff --git a/src/app/state/map/actions/active-map-item.actions.ts b/src/app/state/map/actions/active-map-item.actions.ts index 8d02e84e5..9b12f1b23 100644 --- a/src/app/state/map/actions/active-map-item.actions.ts +++ b/src/app/state/map/actions/active-map-item.actions.ts @@ -12,6 +12,7 @@ export const ActiveMapItemActions = createActionGroup({ events: { 'Add Active Map Item': props<{activeMapItem: ActiveMapItem; position: number}>(), 'Remove Active Map Item': props<{activeMapItem: ActiveMapItem}>(), + 'Replace Active Map Item': props<{modifiedActiveMapItem: ActiveMapItem}>(), 'Remove All Active Map Items': emptyProps(), 'Remove Temporary Active Map Item': props<{activeMapItem: ActiveMapItem}>(), 'Remove All Temporary Active Map Items': emptyProps(), diff --git a/src/app/state/map/effects/active-map-item.effects.spec.ts b/src/app/state/map/effects/active-map-item.effects.spec.ts index 532297eb2..dc94bea48 100644 --- a/src/app/state/map/effects/active-map-item.effects.spec.ts +++ b/src/app/state/map/effects/active-map-item.effects.spec.ts @@ -20,7 +20,13 @@ import {MapUiActions} from '../actions/map-ui.actions'; import {FeatureInfoActions} from '../actions/feature-info.actions'; import {TimeExtent} from '../../../map/interfaces/time-extent.interface'; import {ActiveMapItemFactory} from '../../../shared/factories/active-map-item.factory'; -import {FilterConfiguration, Map} from '../../../shared/interfaces/topic.interface'; +import { + FilterConfiguration, + Map, + MapLayer, + TimeSliderConfiguration, + TimeSliderLayerSource, +} from '../../../shared/interfaces/topic.interface'; import {MapConfigActions} from '../actions/map-config.actions'; import {FavouriteBaseConfig} from '../../../shared/interfaces/favourite.interface'; import {PointWithSrs} from '../../../shared/interfaces/geojson-types-with-srs.interface'; @@ -30,6 +36,7 @@ import {DrawingActions} from '../actions/drawing.actions'; import {LayerCatalogActions} from '../actions/layer-catalog.actions'; import {SearchActions} from '../../app/actions/search.actions'; import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import {Gb2WmsActiveMapItem} from '../../../map/models/implementations/gb2-wms.model'; describe('ActiveMapItemEffects', () => { let actions$: Observable; @@ -557,4 +564,67 @@ describe('ActiveMapItemEffects', () => { }); }); }); + + describe('setTimeSliderExtent$', () => { + it('dispatches nothing if item does not exist', fakeAsync(() => { + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + const activeMapItem = createGb2WmsMapItemMock('id'); + store.overrideSelector(selectItems, []); + + let newAction; + actions$ = of(ActiveMapItemActions.setTimeSliderExtent({timeExtent, activeMapItem})); + effects.setTimeSliderExtent$.subscribe((action) => (newAction = action)); + flush(); + + expect(newAction).toBeUndefined(); + })); + + it('sets the time extent and reevaluates all layer visibilities, dispatches replaceActiveMapItem', (done: DoneFn) => { + const timeExtent: TimeExtent = { + start: new Date(2023, 0, 1), + end: new Date(2023, 11, 31), + }; + const mapMock: Partial = {id: 'id'}; + mapMock.layers = [ + {layer: 'layer01', visible: false} as MapLayer, + {layer: 'layer02', visible: false} as MapLayer, + {layer: 'layer03', visible: false} as MapLayer, + ]; + mapMock.timeSliderConfiguration = { + dateFormat: 'YYYY-MM-DD', + sourceType: 'layer', + source: { + layers: [ + {layerName: 'layer01', date: '2022-06-30'}, + {layerName: 'layer02', date: '2023-06-30'}, + {layerName: 'layer03', date: '2024-06-30'}, + ], + } as TimeSliderLayerSource, + } as TimeSliderConfiguration; + mapMock.initialTimeSliderExtent = timeExtent; + const activeMapItem = ActiveMapItemFactory.createGb2WmsMapItem(mapMock); + store.overrideSelector(selectItems, [activeMapItem]); + + actions$ = of(ActiveMapItemActions.setTimeSliderExtent({timeExtent, activeMapItem})); + const expectedAction = ActiveMapItemActions.replaceActiveMapItem({modifiedActiveMapItem: activeMapItem}); + effects.setTimeSliderExtent$.subscribe(({modifiedActiveMapItem, type}) => { + const expectedTimeExtent = timeExtent; + const expectedLayers: Partial[] = [ + {layer: 'layer01', visible: false}, + {layer: 'layer02', visible: true}, + {layer: 'layer03', visible: false}, + ]; + + expect(type).toEqual(expectedAction.type); + expect(modifiedActiveMapItem).toBeInstanceOf(Gb2WmsActiveMapItem); + expect((modifiedActiveMapItem).settings.timeSliderExtent).toEqual(expectedTimeExtent); + expect((modifiedActiveMapItem).settings.layers).toEqual(expectedLayers); + + done(); + }); + }); + }); }); diff --git a/src/app/state/map/effects/active-map-item.effects.ts b/src/app/state/map/effects/active-map-item.effects.ts index 00177942e..248b4ad22 100644 --- a/src/app/state/map/effects/active-map-item.effects.ts +++ b/src/app/state/map/effects/active-map-item.effects.ts @@ -26,6 +26,8 @@ import {DrawingActiveMapItem} from '../../../map/models/implementations/drawing. import {DrawingActions} from '../actions/drawing.actions'; import {LayerCatalogActions} from '../actions/layer-catalog.actions'; import {SearchActions} from '../../app/actions/search.actions'; +import {TimeSliderService} from '../../../map/services/time-slider.service'; +import {produce} from 'immer'; @Injectable() export class ActiveMapItemEffects { @@ -354,11 +356,44 @@ export class ActiveMapItemEffects { ); }); + public setTimeSliderExtent$ = createEffect(() => { + return this.actions$.pipe( + ofType(ActiveMapItemActions.setTimeSliderExtent), + concatLatestFrom(() => this.store.select(selectItems)), + map(([{activeMapItem, timeExtent}, activeMapItems]) => { + const existingMapItem = activeMapItems + .filter(isActiveMapItemOfType(Gb2WmsActiveMapItem)) + .find((mapItem) => mapItem.id === activeMapItem.id); + + if (!existingMapItem) { + return undefined; + } + + return produce(existingMapItem, (draft) => { + draft.settings.timeSliderExtent = timeExtent; + draft.settings.layers.forEach((layer) => { + const isVisible = this.timeSliderService.isLayerVisible( + layer, + existingMapItem.settings.timeSliderConfiguration, + existingMapItem.settings.timeSliderExtent, + ); + if (isVisible !== undefined) { + layer.visible = isVisible; + } + }); + }); + }), + filter((modifiedActiveMapItem) => modifiedActiveMapItem !== undefined), + map((modifiedActiveMapItem) => ActiveMapItemActions.replaceActiveMapItem({modifiedActiveMapItem})), + ); + }); + constructor( private readonly actions$: Actions, @Inject(MAP_SERVICE) private readonly mapService: MapService, private readonly gb3TopicsService: Gb3TopicsService, private readonly store: Store, private readonly configService: ConfigService, + private readonly timeSliderService: TimeSliderService, ) {} } diff --git a/src/app/state/map/effects/share-link.effects.spec.ts b/src/app/state/map/effects/share-link.effects.spec.ts index f9f21108d..9e9fcc2e2 100644 --- a/src/app/state/map/effects/share-link.effects.spec.ts +++ b/src/app/state/map/effects/share-link.effects.spec.ts @@ -34,8 +34,9 @@ import {DrawingLayerPrefix, UserDrawingLayer} from '../../../shared/enums/drawin import {ActiveMapItemFactory} from '../../../shared/factories/active-map-item.factory'; import {selectIsAuthenticated, selectIsAuthenticationInitialized} from '../../auth/reducers/auth-status.reducer'; import {MapRestoreItem} from '../../../shared/interfaces/map-restore-item.interface'; -import {TimeExtentUtils} from '../../../shared/utils/time-extent.utils'; import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import {TimeService} from '../../../shared/interfaces/time-service.interface'; +import {TIME_SERVICE} from '../../../app.module'; function createActiveMapItemsFromConfigs(activeMapItemConfigurations: ActiveMapItemConfiguration[]): ActiveMapItem[] { return activeMapItemConfigurations.map( @@ -54,88 +55,8 @@ describe('ShareLinkEffects', () => { let gb3ShareLinkService: Gb3ShareLinkService; let authServiceMock: jasmine.SpyObj; let favouriteServiceMock: jasmine.SpyObj; - - const shareLinkItemMock: ShareLinkItem = { - basemapId: 'arelkbackgroundzh', - center: {x: 2675158, y: 1259964}, - scale: 18000, - content: [ - { - id: 'StatGebAlterZH', - mapId: 'StatGebAlterZH', - layers: [ - { - id: 132494, - layer: 'geb-alter_wohnen', - visible: true, - }, - { - id: 132495, - layer: 'geb-alter_grau', - visible: false, - }, - { - id: 132496, - layer: 'geb-alter_2', - visible: true, - }, - ], - opacity: 0.5, - visible: true, - isSingleLayer: false, - timeExtent: {start: TimeExtentUtils.parseDefaultUTCDate('1000'), end: TimeExtentUtils.parseDefaultUTCDate('2020')}, - attributeFilters: [ - { - parameter: 'FILTER_GEBART', - name: 'Anzeigeoptionen nach Hauptnutzung', - activeFilters: [ - {name: 'Wohnen', isActive: false}, - {name: 'Andere', isActive: false}, - { - name: 'Gewerbe und Verwaltung', - isActive: false, - }, - ], - }, - ], - }, - { - id: 'Lageklassen2003ZH', - mapId: 'Lageklassen2003ZH', - layers: [ - { - id: 135765, - layer: 'lageklassen-2003-flaechen', - visible: true, - }, - { - id: 135775, - layer: 'lageklassen-2003-einzelobjekte', - visible: true, - }, - ], - opacity: 1, - visible: true, - isSingleLayer: false, - timeExtent: undefined, - attributeFilters: undefined, - }, - ], - drawings: { - type: 'Vector', - geojson: { - type: 'FeatureCollection', - features: [{type: 'Feature', geometry: {type: 'Point', coordinates: [0, 1]}, properties: {text: 'drawing', style: ''}}], - }, - } as Gb3VectorLayer, - measurements: { - type: 'Vector', - geojson: { - type: 'FeatureCollection', - features: [{type: 'Feature', geometry: {type: 'Point', coordinates: [0, 1]}, properties: {text: 'measurement', style: ''}}], - }, - } as Gb3VectorLayer, - }; + let timeService: TimeService; + let shareLinkItemMock: ShareLinkItem; beforeEach(() => { actions$ = new Observable(); @@ -155,7 +76,89 @@ describe('ShareLinkEffects', () => { }); effects = TestBed.inject(ShareLinkEffects); gb3ShareLinkService = TestBed.inject(Gb3ShareLinkService); + timeService = TestBed.inject(TIME_SERVICE); store = TestBed.inject(MockStore); + shareLinkItemMock = { + basemapId: 'arelkbackgroundzh', + center: {x: 2675158, y: 1259964}, + scale: 18000, + content: [ + { + id: 'StatGebAlterZH', + mapId: 'StatGebAlterZH', + layers: [ + { + id: 132494, + layer: 'geb-alter_wohnen', + visible: true, + }, + { + id: 132495, + layer: 'geb-alter_grau', + visible: false, + }, + { + id: 132496, + layer: 'geb-alter_2', + visible: true, + }, + ], + opacity: 0.5, + visible: true, + isSingleLayer: false, + timeExtent: {start: timeService.createUTCDateFromString('1000'), end: timeService.createUTCDateFromString('2020')}, + attributeFilters: [ + { + parameter: 'FILTER_GEBART', + name: 'Anzeigeoptionen nach Hauptnutzung', + activeFilters: [ + {name: 'Wohnen', isActive: false}, + {name: 'Andere', isActive: false}, + { + name: 'Gewerbe und Verwaltung', + isActive: false, + }, + ], + }, + ], + }, + { + id: 'Lageklassen2003ZH', + mapId: 'Lageklassen2003ZH', + layers: [ + { + id: 135765, + layer: 'lageklassen-2003-flaechen', + visible: true, + }, + { + id: 135775, + layer: 'lageklassen-2003-einzelobjekte', + visible: true, + }, + ], + opacity: 1, + visible: true, + isSingleLayer: false, + timeExtent: undefined, + attributeFilters: undefined, + }, + ], + drawings: { + type: 'Vector', + geojson: { + type: 'FeatureCollection', + features: [{type: 'Feature', geometry: {type: 'Point', coordinates: [0, 1]}, properties: {text: 'drawing', style: ''}}], + }, + } as Gb3VectorLayer, + measurements: { + type: 'Vector', + geojson: { + type: 'FeatureCollection', + features: [{type: 'Feature', geometry: {type: 'Point', coordinates: [0, 1]}, properties: {text: 'measurement', style: ''}}], + }, + } as Gb3VectorLayer, + }; }); describe('loadShareLinkItem$', () => { @@ -252,20 +255,25 @@ describe('ShareLinkEffects', () => { describe('Initialize the application based on a share link', () => { const expectedId = 'abcd-efgh-ijkl-mnop'; - const expectedItem = shareLinkItemMock; const expectedOriginalError = new ShareLinkPropertyCouldNotBeValidated("He's dead, Jim."); const expectedTopics: Topic[] = []; - const expectedCompleteItem: MapRestoreItem = { - activeMapItems: createActiveMapItemsFromConfigs(shareLinkItemMock.content), - scale: shareLinkItemMock.scale, - basemapId: shareLinkItemMock.basemapId, - x: shareLinkItemMock.center.x, - y: shareLinkItemMock.center.y, - drawings: [ - {id: 'mockDrawing1', source: UserDrawingLayer.Drawings, geometry: {type: 'Point', srs: 2056, coordinates: [0, 0]}}, - {id: 'mockDrawing2', source: UserDrawingLayer.Measurements, geometry: {type: 'Point', srs: 2056, coordinates: [0, 0]}}, - ] as Gb3StyledInternalDrawingRepresentation[], - }; + let expectedCompleteItem: MapRestoreItem; + let expectedItem: ShareLinkItem; + + beforeEach(() => { + expectedItem = shareLinkItemMock; + expectedCompleteItem = { + activeMapItems: createActiveMapItemsFromConfigs(shareLinkItemMock.content), + scale: shareLinkItemMock.scale, + basemapId: shareLinkItemMock.basemapId, + x: shareLinkItemMock.center.x, + y: shareLinkItemMock.center.y, + drawings: [ + {id: 'mockDrawing1', source: UserDrawingLayer.Drawings, geometry: {type: 'Point', srs: 2056, coordinates: [0, 0]}}, + {id: 'mockDrawing2', source: UserDrawingLayer.Measurements, geometry: {type: 'Point', srs: 2056, coordinates: [0, 0]}}, + ] as Gb3StyledInternalDrawingRepresentation[], + }; + }); describe('Action: Initialize Application Based On Id', () => { describe('waitForAuthenticationStatusToBeLoaded$', () => { diff --git a/src/app/state/map/reducers/active-map-item.reducer.spec.ts b/src/app/state/map/reducers/active-map-item.reducer.spec.ts index aff26d192..4a5251364 100644 --- a/src/app/state/map/reducers/active-map-item.reducer.spec.ts +++ b/src/app/state/map/reducers/active-map-item.reducer.spec.ts @@ -8,16 +8,9 @@ import {Gb2WmsActiveMapItem} from '../../../map/models/implementations/gb2-wms.m import {LoadingState} from '../../../shared/types/loading-state.type'; import {ViewProcessState} from '../../../shared/types/view-process-state.type'; import {isActiveMapItemOfType} from '../../../shared/type-guards/active-map-item-type.type-guard'; -import { - FilterConfiguration, - Map, - MapLayer, - TimeSliderConfiguration, - TimeSliderLayerSource, -} from '../../../shared/interfaces/topic.interface'; +import {FilterConfiguration, Map} from '../../../shared/interfaces/topic.interface'; import {ActiveMapItemFactory} from '../../../shared/factories/active-map-item.factory'; import {FavouriteBaseConfig} from '../../../shared/interfaces/favourite.interface'; -import {TimeExtent} from '../../../map/interfaces/time-extent.interface'; describe('ActiveMapItem Reducer', () => { const activeMapItemsMock: ActiveMapItem[] = [ @@ -343,48 +336,6 @@ describe('ActiveMapItem Reducer', () => { }); }); - describe('setTimeSliderExtent', () => { - it('sets the time extend and reevaluates all layer visibilities', () => { - const timeExtent: TimeExtent = { - start: new Date(2023, 0, 1), - end: new Date(2023, 11, 31), - }; - const mapMock: Partial = {id: 'id'}; - mapMock.layers = [ - {layer: 'layer01', visible: false} as MapLayer, - {layer: 'layer02', visible: false} as MapLayer, - {layer: 'layer03', visible: false} as MapLayer, - ]; - mapMock.timeSliderConfiguration = { - dateFormat: 'YYYY-MM-DD', - sourceType: 'layer', - source: { - layers: [ - {layerName: 'layer01', date: '2022-06-30'}, - {layerName: 'layer02', date: '2023-06-30'}, - {layerName: 'layer03', date: '2024-06-30'}, - ], - } as TimeSliderLayerSource, - } as TimeSliderConfiguration; - const activeMapItem = ActiveMapItemFactory.createGb2WmsMapItem(mapMock); - existingState.items = [activeMapItem]; - - const action = ActiveMapItemActions.setTimeSliderExtent({timeExtent, activeMapItem}); - const state = reducer(existingState, action); - - const expectedTimeExtent = timeExtent; - const expectedLayers: Partial[] = [ - {layer: 'layer01', visible: false}, - {layer: 'layer02', visible: true}, - {layer: 'layer03', visible: false}, - ]; - - expect(state.items[0]).toBeInstanceOf(Gb2WmsActiveMapItem); - expect((state.items[0]).settings.timeSliderExtent).toEqual(expectedTimeExtent); - expect((state.items[0]).settings.layers).toEqual(expectedLayers); - }); - }); - describe('setAttributeFilterValueState', () => { const filterConfigurationsMock: FilterConfiguration[] = [ { @@ -484,4 +435,19 @@ describe('ActiveMapItem Reducer', () => { expect(state.items.filter(isActiveMapItemOfType(Gb2WmsActiveMapItem)).every((item) => item.settings.isNoticeMarkedAsRead)).toBeTrue(); }); }); + + describe('replaceActiveMapItem', () => { + it('replaces the active map item correctly', () => { + const modifiedActiveMapItem = structuredClone(activeMapItemsMock[1]); + const modifiedOpacity = activeMapItemsMock[1].opacity + 1337; + modifiedActiveMapItem.opacity = modifiedOpacity; + + existingState.items = activeMapItemsMock; + + const action = ActiveMapItemActions.replaceActiveMapItem({modifiedActiveMapItem}); + const state = reducer(existingState, action); + + expect((state.items[1]).opacity).toEqual(modifiedOpacity); + }); + }); }); diff --git a/src/app/state/map/reducers/active-map-item.reducer.ts b/src/app/state/map/reducers/active-map-item.reducer.ts index 9a4ef43bb..7628abde3 100644 --- a/src/app/state/map/reducers/active-map-item.reducer.ts +++ b/src/app/state/map/reducers/active-map-item.reducer.ts @@ -4,7 +4,6 @@ import {ActiveMapItemState} from '../states/active-map-item.state'; import {produce} from 'immer'; import {isActiveMapItemOfType} from '../../../shared/type-guards/active-map-item-type.type-guard'; import {Gb2WmsActiveMapItem} from '../../../map/models/implementations/gb2-wms.model'; -import {ActiveTimeSliderLayersUtils} from '../../../map/utils/active-time-slider-layers.utils'; export const activeMapItemFeatureKey = 'activeMapItem'; @@ -137,26 +136,6 @@ export const activeMapItemFeature = createFeature({ }); }), ), - on( - ActiveMapItemActions.setTimeSliderExtent, - produce((draft, {timeExtent, activeMapItem}) => { - draft.items.filter(isActiveMapItemOfType(Gb2WmsActiveMapItem)).forEach((mapItem) => { - if (mapItem.id === activeMapItem.id) { - mapItem.settings.timeSliderExtent = timeExtent; - mapItem.settings.layers.forEach((layer) => { - const isVisible = ActiveTimeSliderLayersUtils.isLayerVisible( - layer, - mapItem.settings.timeSliderConfiguration, - mapItem.settings.timeSliderExtent, - ); - if (isVisible !== undefined) { - layer.visible = isVisible; - } - }); - } - }); - }), - ), on( ActiveMapItemActions.setAttributeFilterValueState, produce((draft, {isFilterValueActive, filterValueName, attributeFilterParameter, activeMapItem}) => { @@ -192,6 +171,16 @@ export const activeMapItemFeature = createFeature({ }); }), ), + on( + ActiveMapItemActions.replaceActiveMapItem, + produce((draft, {modifiedActiveMapItem}) => { + const existing = draft.items.find((mapItem) => mapItem.id === modifiedActiveMapItem.id); + if (existing) { + const index = draft.items.indexOf(existing); + draft.items.splice(index, 1, modifiedActiveMapItem); + } + }), + ), ), }); diff --git a/src/app/state/map/selectors/active-map-item-configuration.selector.spec.ts b/src/app/state/map/selectors/active-map-item-configuration.selector.spec.ts index a77f9248c..5ee445544 100644 --- a/src/app/state/map/selectors/active-map-item-configuration.selector.spec.ts +++ b/src/app/state/map/selectors/active-map-item-configuration.selector.spec.ts @@ -1,5 +1,4 @@ import {selectActiveMapItemConfigurations} from './active-map-item-configuration.selector'; -import {TimeExtentUtils} from '../../../shared/utils/time-extent.utils'; import {ActiveMapItemConfiguration} from '../../../shared/interfaces/active-map-item-configuration.interface'; import {ActiveMapItemFactory} from '../../../shared/factories/active-map-item.factory'; import {Map} from '../../../shared/interfaces/topic.interface'; @@ -30,10 +29,7 @@ describe('selectActiveMapItemConfiguration', () => { undefined, true, 0.71, - { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), - }, + undefined, [ { parameter: 'FILTER_GEBART', @@ -89,10 +85,7 @@ describe('selectActiveMapItemConfiguration', () => { ], }, ], - timeExtent: { - start: TimeExtentUtils.parseDefaultUTCDate('1000-01-01T00:00:00.000Z'), - end: TimeExtentUtils.parseDefaultUTCDate('2020-01-01T00:00:00.000Z'), - }, + timeExtent: undefined, }, ]; diff --git a/src/app/testing/map-testing/share-link-item-test.utils.ts b/src/app/testing/map-testing/share-link-item-test.utils.ts index 4eda3ad0d..67e1b218f 100644 --- a/src/app/testing/map-testing/share-link-item-test.utils.ts +++ b/src/app/testing/map-testing/share-link-item-test.utils.ts @@ -1,9 +1,8 @@ import {ShareLinkItem} from '../../shared/interfaces/share-link.interface'; import {MinimalGeometriesUtils} from './minimal-geometries.utils'; -import {TimeExtentUtils} from '../../shared/utils/time-extent.utils'; export class ShareLinkItemTestUtils { - public static createShareLinkItem(): ShareLinkItem { + public static createShareLinkItem(timeExtentStart: Date, timeExtentEnd: Date): ShareLinkItem { const {srs, ...minimalPolygonGeometry} = MinimalGeometriesUtils.getMinimalPolygon(2056); return { basemapId: 'arelkbackgroundzh', @@ -33,7 +32,7 @@ export class ShareLinkItemTestUtils { opacity: 0.5, visible: true, isSingleLayer: false, - timeExtent: {start: TimeExtentUtils.parseDefaultUTCDate('1000'), end: TimeExtentUtils.parseDefaultUTCDate('2020')}, + timeExtent: {start: timeExtentStart, end: timeExtentEnd}, attributeFilters: [ { parameter: 'FILTER_GEBART', diff --git a/src/test.ts b/src/test.ts index bdbddfe24..56306628d 100644 --- a/src/test.ts +++ b/src/test.ts @@ -3,6 +3,17 @@ import 'zone.js/testing'; import {getTestBed} from '@angular/core/testing'; import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; +import {TIME_SERVICE} from './app/app.module'; +import {timeServiceFactory} from './app/shared/factories/time-service.factory'; // First, initialize the Angular testing environment. -getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting([ + // we provide the time service here so it is the same as in the running app and we don't have to inject it in each test + { + provide: TIME_SERVICE, + useFactory: timeServiceFactory, + }, + ]), +);