File

services/app.service.ts

Description

ArcGIS hazard service info.

Index

Properties

Properties

name
name: string
Type : string

Name hazard map

type
type: string
Type : string

Map type

import {Location as LocationService} from '@angular/common';
import {HttpClient, HttpParams} from '@angular/common/http';
import {computed, Injectable, NgZone, Signal, signal} from '@angular/core';
import {
  AbstractControl,
  FormBuilder,
  FormControl,
  Validators,
} from '@angular/forms';
import {ActivatedRoute} from '@angular/router';
import {HazardService} from '@ghsc/nshmp-lib-ng/hazard';
import {mapUtils} from '@ghsc/nshmp-lib-ng/map';
import {
  NshmpService,
  nshmpUtils,
  ReturnPeriod,
  SpinnerService,
} from '@ghsc/nshmp-lib-ng/nshmp';
import {MapBaseLayer} from '@ghsc/nshmp-utils-ts/libs/leaflet';
import * as nshmpLeaflet from '@ghsc/nshmp-utils-ts/libs/leaflet';
import {FaultSectionProperties} from '@ghsc/nshmp-utils-ts/libs/nshmp-haz/www/fault-sections-service';
import {
  FeaturesUsageResponse,
  FeatureType,
} from '@ghsc/nshmp-utils-ts/libs/nshmp-haz/www/features-service';
import {NshmMetadata} from '@ghsc/nshmp-utils-ts/libs/nshmp-haz/www/nshm-service';
import {
  SourceLogicTreesMetadata,
  SourceLogicTreesUsage,
} from '@ghsc/nshmp-utils-ts/libs/nshmp-haz/www/source-logic-trees-service';
import {Imt} from '@ghsc/nshmp-utils-ts/libs/nshmp-lib/gmm';
import {
  NshmId,
  nshmRegion,
  nshmYear,
} from '@ghsc/nshmp-utils-ts/libs/nshmp-lib/nshm';
import {Parameter} from '@ghsc/nshmp-utils-ts/libs/nshmp-ws-utils/metadata';
import deepEqual from 'deep-equal';
import * as L from 'leaflet';
import {environment} from 'projects/nshmp-apps/src/environments/environment';
import {AppServiceModel} from 'projects/nshmp-apps/src/shared/models/app-service.model';
import {apps} from 'projects/nshmp-apps/src/shared/utils/applications.utils';
import {catchError, forkJoin, map, mergeMap, Observable} from 'rxjs';

import {ControlForm, LatestEarthquakeTime} from '../models/control-form.model';
import {EarthquakeFeatureProperties} from '../models/earthquake-props.model';
import {HazardTile} from '../models/hazard-tile.model';
import {Layers} from '../models/layers.model';
import {
  AppState,
  EarthquakeInfoPopupData,
  InfoPopupData,
} from '../models/state.model';

/**
 * ArcGIS hazard service info.
 */
interface ArcService {
  /** Name hazard map */
  name: string;
  /** Map type */
  type: string;
}

/**
 * ArcGIS hazard service response.
 */
interface ArcServiceResponse {
  /** Hazard maps */
  services: ArcService[];
}

interface Query {
  /** Whether devollement toggle is on */
  hasDecollementLayer: string;
  /** Whether latest earthquake toggle is on */
  hasEarthquakesLayer: string;
  /** Whether fault sections toggle is on */
  hasFaultSectionLayer: string;
  /** Whether zone source toggle is on */
  hasHazardTiles: string;
  /** Whether interface sections toggle is on */
  hasInterfaceSectionsLayer: string;
  /** Whether NSHM boundary toggle is on*/
  hasNshmBoundaryLayer: string;
  /** Whether test sites toggle is on */
  hasTestSitesLayer: string;
  /** Whether zone sources is toggled on */
  hasZoneSourcesLayer: string;
  /** IMT for hazard map tile */
  hazardTileImt: string;
  /** Return period for hazard map tile */
  hazardTileReturnPeriod: ReturnPeriod;
  /** Year for hazard map tile */
  hazardTileYear: string;
  /** Latest earthquake feed time frame */
  latestEarthquakeTime: LatestEarthquakeTime;
  /** The NSHM */
  model: NshmId;
  /** Leaflet overlay opactiy */
  overlayOpacity: string;
}

/**
 * Entrypoint to store for data mapping application.
 */
@Injectable({
  providedIn: 'root',
})
export class AppService implements AppServiceModel<AppState, ControlForm> {
  private faultZoomInWeight = 4;
  private faultZoomOutWeight = 1;
  private featuresHoverWeight = 5;
  /** nshmp-haz-ws web config */
  private nshmpHazWs = environment.webServices.nshmpHazWs;
  /** Endpoint to NSHMs */
  private nshmsEndpoint = this.nshmpHazWs.services.nshms;
  private opacity = 0.6;
  /** nshmp-haz web services */
  private services = this.nshmpHazWs.services.curveServices;

  readonly formGroup = this.formBuilder.group<ControlForm>(
    this.defaultFormValues(),
  );
  /** Application state */
  readonly state = signal<AppState>(this.initialState());

  constructor(
    private formBuilder: FormBuilder,
    private spinnerService: SpinnerService,
    private nshmpService: NshmpService,
    private hazardService: HazardService,
    private http: HttpClient,
    private zone: NgZone,
    private route: ActivatedRoute,
    private location: LocationService,
  ) {
    this.addValidators();
  }

  /**
   * Returns the avialable models, as `Parameter`s, observable.
   */
  get availableModels(): Signal<Parameter[]> {
    return computed(() => this.state().availableModels);
  }

  /**
   * Returns the map bounds observable.
   */
  get bounds(): Signal<L.LatLngBounds> {
    return computed(() => this.state().bounds);
  }

  get earthquakeInfoPopupData(): Signal<EarthquakeInfoPopupData> {
    return computed(() => this.state().earthquakeInfoPopupData);
  }

  /**
   * Returns the hazard tiles.
   */
  get hazardTiles(): Signal<HazardTile[]> {
    return computed(() => this.state().hazardTiles);
  }

  /**
   * Returns the info popup data.
   */
  get infoPopupData(): Signal<InfoPopupData> {
    return computed(() => this.state().infoPopupData);
  }

  /**
   * Returns the Leaflet map layers observable.
   */
  get layers(): Signal<Layers> {
    return computed(() => this.state().layers);
  }

  /**
   * Returns the Leaflet map observable.
   */
  get map(): Signal<L.Map> {
    return computed(() => this.state().map);
  }

  /**
   * Set the Leaflet map.
   *
   * @param map The Leaflet map
   */
  set map(map: L.Map) {
    this.updateState({map});
  }

  /**
   * Returns the Leaflet map options observable.
   */
  get mapOptions(): Signal<L.MapOptions> {
    return computed(() => this.state().mapOptions);
  }

  get nshmService(): Signal<NshmMetadata> {
    return computed(() =>
      this.state().nshmServices.find(
        nshm => nshm.model === this.formGroup.getRawValue().model,
      ),
    );
  }

  get sourceFeaturesUsage(): Signal<FeaturesUsageResponse> {
    return computed(() =>
      this.state().sourceFeaturesUsages.get(this.formGroup.getRawValue().model),
    );
  }

  /**
   * Returns the trees usage.
   */
  get treesUsageResponse(): Signal<SourceLogicTreesUsage> {
    return computed(() =>
      this.state().treesUsageResponses?.get(this.formGroup.getRawValue().model),
    );
  }

  addValidators(): void {
    this.formGroup.controls.model.addValidators(control =>
      Validators.required(control),
    );
  }

  /**
   * Set the base layer on the map.
   *
   * @param baseLayer The base layer to set
   */
  baseLayer(baseLayer: MapBaseLayer): void {
    this.updateState({
      layers: {
        ...this.layers(),
        baseLayer: nshmpLeaflet.baseLayer(baseLayer),
        hazardLayer: this.createHazardLayer(
          this.findTile(this.state().hazardTiles, this.formGroup.getRawValue()),
          this.formGroup.getRawValue().overlayOpacity,
        ),
      },
    });
  }

  /**
   * Call latest earthquakes service.
   */
  callEarthquakesService(): void {
    this.updateState({
      layers: {
        ...this.layers(),
        earthquakesLayer: null,
      },
    });

    const time = this.formGroup
      .getRawValue()
      .latestEarthquakeTime.toLowerCase();
    const service = environment.webServices.latestEarthquakes;
    const url = `${service}/2.5_${time}.geojson`;

    this.http
      .get<GeoJSON.FeatureCollection<GeoJSON.Point>>(url)
      .pipe(catchError((error: Error) => this.nshmpService.throwError$(error)))
      .subscribe(response => {
        this.updateState({
          layers: {
            ...this.layers(),
            earthquakesLayer: this.earthquakesToLayer(response),
          },
        });
      });
  }

  /**
   * Call the features service with a specific feature type.
   *
   * @param featureType The feature type
   */
  callFeaturesService(featureType: FeatureType): void {
    const faultSectionsUrl = `${this.nshmService().url}${this.services.features}/${featureType}/true`;

    this.http
      .get<GeoJSON.FeatureCollection>(faultSectionsUrl)
      .pipe(catchError((error: Error) => this.nshmpService.throwError$(error)))
      .subscribe(serviceResponse => {
        const layer = this.featureTypeToLayer(serviceResponse, featureType);

        switch (featureType) {
          case FeatureType.DECOLLEMENT: {
            this.updateState({
              layers: {
                ...this.layers(),
                decollementLayer: layer,
              },
            });
            break;
          }
          case FeatureType.FAULT: {
            this.updateState({
              layers: {
                ...this.layers(),
                faultSectionLayer: layer,
              },
            });
            break;
          }
          case FeatureType.INTERFACE: {
            this.updateState({
              layers: {
                ...this.layers(),
                interfaceSectionsLayer: layer,
              },
            });
            break;
          }
          case FeatureType.ZONE: {
            this.updateState({
              layers: {
                ...this.layers(),
                zoneSourcesLayer: layer,
              },
            });
            break;
          }
          default:
            throw new Error(`Feature type [${featureType}] not allowed`);
        }

        this.updateState({
          bounds: this.extendBounds(layer.getBounds(), this.layers()),
        });
      });
  }

  callFeaturesUsages$(): Observable<void> {
    const usages$ = this.state().nshmServices.map(
      nshm =>
        this.nshmpService
          .callService$<FeaturesUsageResponse>(
            `${nshm.url}${this.services.features}`,
          )
          .pipe(
            map(sourceFeaturesUsage => {
              return {
                nshm,
                sourceFeaturesUsage,
              };
            }),
          ),
      catchError((error: Error) => this.nshmpService.throwError$(error)),
    );

    return forkJoin(usages$).pipe(
      map(usages => {
        const sourceFeaturesUsages = new Map<string, FeaturesUsageResponse>();

        usages.forEach(usage => {
          sourceFeaturesUsages.set(usage.nshm.model, usage.sourceFeaturesUsage);
        });

        this.updateState({
          sourceFeaturesUsages,
        });

        this.resetSourceFeatureControls();
        this.callService();
      }),
    );
  }

  /**
   * Call the map service to get NSHM map and boundary.
   */
  callService(): void {
    const url = `${this.nshmService().url}${this.services.map}?raw=true`;

    this.http
      .get<GeoJSON.FeatureCollection>(url)
      .pipe(catchError((error: Error) => this.nshmpService.throwError$(error)))
      .subscribe(response => {
        const nshmBorder = response.features.find(
          feature => feature.id !== 'Extents',
        );

        this.updateState({
          layers: {
            ...this.layers(),
            nshmBoundaryLayer: L.geoJSON(nshmBorder, {
              style: {
                color: 'black',
                fill: false,
                weight: 1,
              },
            }),
          },
        });

        const {hasFaultSectionLayer, hasTestSitesLayer} =
          this.formGroup.getRawValue();
        let bounds = this.layers().nshmBoundaryLayer.getBounds();
        const {faultSectionLayer, testSitesLayer} = this.layers();

        if (hasFaultSectionLayer && faultSectionLayer) {
          bounds = bounds.extend(faultSectionLayer.getBounds());
        }
        if (hasTestSitesLayer && testSitesLayer) {
          bounds = bounds.extend(testSitesLayer.getBounds());
        }

        this.updateState({
          bounds,
        });
      });
  }

  /**
   * Call test sites service.
   */
  callTestSitesService(): void {
    const url = `${this.nshmService().url}${this.services.sites}?raw=true`;

    this.http
      .get<GeoJSON.FeatureCollection>(url)
      .pipe(catchError((error: Error) => this.nshmpService.throwError$(error)))
      .subscribe(response => {
        const testSitesLayer = mapUtils.testSitesToLayer(response);
        let bounds = testSitesLayer.getBounds();

        const {hasFaultSectionLayer} = this.formGroup.getRawValue();
        const {faultSectionLayer} = this.layers();

        if (hasFaultSectionLayer && faultSectionLayer) {
          bounds = bounds.extend(faultSectionLayer.getBounds());
        }

        this.updateState({
          bounds,
          layers: {
            ...this.layers(),
            testSitesLayer,
          },
        });
      });
  }

  /**
   * Create the hazard layer from hazard tile info.
   *
   * @param hazardTile The hazard tile
   */
  createHazardLayer(hazardTile: HazardTile, opacity: number): L.TileLayer {
    if (hazardTile === undefined) {
      return null;
    }

    const url = `${environment.webServices.hazardTiles}/${hazardTile.mapName}/MapServer/tile/{z}/{y}/{x}`;

    return L.tileLayer(url, {
      attribution: '',
      id: 'us-hazard',
      maxZoom: 12,
      minZoom: 0,
      opacity: opacity / 100,
    });
  }

  /**
   * Default control panel form field values.
   */
  defaultFormValues(): ControlForm {
    return {
      hasDecollementLayer: null,
      hasEarthquakesLayer: null,
      hasFaultSectionLayer: null,
      hasHazardTiles: null,
      hasInterfaceSectionsLayer: null,
      hasNshmBoundaryLayer: true,
      hasTestSitesLayer: null,
      hasZoneSourcesLayer: null,
      hazardTileImt: null,
      hazardTileReturnPeriod: null,
      hazardTileYear: null,
      latestEarthquakeTime: LatestEarthquakeTime.WEEK,
      model: NshmId.CONUS_2018,
      overlayOpacity: this.opacity * 100,
    };
  }

  /**
   * Dim all layers
   */
  dimLayers(): void {
    const layers = this.layers();

    this.dimLayer(layers.decollementLayer);
    this.dimLayer(layers.faultSectionLayer);
    this.dimLayer(layers.interfaceSectionsLayer);
    this.dimLayer(layers.zoneSourcesLayer);
    this.dimLayer(layers.testSitesLayer);
  }

  faultsWeight(): number {
    return this.state().map.getZoom() >= 7
      ? this.faultZoomInWeight
      : this.faultZoomOutWeight;
  }

  /**
   * Returns the hazard tiles for a specific region, year, and IMT.
   *
   * @param tiles The hazard tiles
   * @param form Control panel form values
   */
  filterTilesByImt(tiles: HazardTile[], form: ControlForm): HazardTile[] {
    return this.filterTilesByYear(tiles, form)
      ?.filter(tile => tile.imt === form.hazardTileImt)
      .sort((a, b) => a.returnPeriod - b.returnPeriod);
  }

  /**
   * Returns the hazard tiles for a specific region.
   *
   * @param tiles The hazard tiles
   * @param form Control panel form values
   */
  filterTilesByRegion(tiles: HazardTile[], form: ControlForm): HazardTile[] {
    return tiles
      ?.filter(tile => nshmRegion(tile.nshm) === nshmRegion(form.model))
      .sort((a, b) => nshmYear(a.nshm) - nshmYear(b.nshm));
  }

  /**
   * Returns the hazard tiles for a specific region and year.
   *
   * @param tiles The hazard tiles
   * @param form Control panel form values
   */
  filterTilesByYear(tiles: HazardTile[], form: ControlForm): HazardTile[] {
    return this.filterTilesByRegion(tiles, form)
      .filter(tile => nshmYear(tile.nshm) === form.hazardTileYear)
      .sort((a, b) => (a.imt.toLowerCase() > b.imt.toLowerCase() ? 1 : -1));
  }

  /**
   * Returns the hazard tile for a specific region, year, IMT, and return period.
   *
   * @param tiles The hazard tiles
   * @param form Control panel form values
   */
  findTile(tiles: HazardTile[], form: ControlForm): HazardTile {
    return this.filterTilesByImt(tiles, form)?.find(
      tile => tile.returnPeriod === form.hazardTileReturnPeriod,
    );
  }

  /**
   * Initialize the application.
   */
  init(): void {
    const spinnerRef = this.spinnerService.show(
      SpinnerService.MESSAGE_METADATA,
    );

    this.callTreesUsage()
      .pipe(
        mergeMap(() =>
          forkJoin([this.callFeaturesUsages$(), this.callHazardTilesUsage()]),
        ),
        catchError(error => {
          this.initialFormSet();
          spinnerRef.close();
          console.error(error);
          return [];
        }),
      )
      .subscribe(() => {
        this.initialFormSet();
        spinnerRef.close();
      });
  }

  /**
   * Application initial state.
   */
  initialState(): AppState {
    const treesUsageResponses: Map<string, SourceLogicTreesUsage> = new Map();
    treesUsageResponses.set(NshmId.CONUS_2018, null);

    return {
      availableModels: [],
      bounds: null,
      earthquakeInfoPopupData: {
        feature: null,
      },
      hazardTiles: [],
      infoPopupData: {
        feature: null,
        featureType: null,
      },
      layers: {
        baseLayer: nshmpLeaflet.baseLayer(MapBaseLayer.OCEAN),
        decollementLayer: null,
        earthquakesLayer: null,
        faultSectionLayer: null,
        hazardLayer: null,
        interfaceSectionsLayer: null,
        nshmBoundaryLayer: null,
        testSitesLayer: null,
        zoneSourcesLayer: null,
      },
      map: null,
      mapOptions: this.defaultMapOptions(),
      nshmServices: [],
      sourceFeaturesUsages: new Map(),
      treesUsageResponses,
    };
  }

  /**
   * Redraw the map.
   */
  mapRedraw(): void {
    setTimeout(() => this.state().map?.invalidateSize());
  }

  /**
   * Reset the style of all layers.
   */
  resetLayers(): void {
    this.layers()
      .testSitesLayer?.getLayers()
      .forEach((layer: L.Marker) => layer.setOpacity(1));

    [
      this.layers().decollementLayer,
      this.layers().faultSectionLayer,
      this.layers().interfaceSectionsLayer,
      this.layers().zoneSourcesLayer,
      this.layers().earthquakesLayer,
    ].forEach(layer => layer?.resetStyle());

    this.layers().faultSectionLayer.setStyle({
      weight: this.faultsWeight(),
    });
  }

  resetState(): void {
    const values = this.formGroup.getRawValue();
    const hazardTiles = this.filterTilesByRegion(this.hazardTiles(), values);
    const defaultTile = [...hazardTiles].pop();
    this.resetSourceFeatureControls();

    this.updateState({
      bounds: this.modelBounds(values.model),
      earthquakeInfoPopupData: this.initialState().earthquakeInfoPopupData,
      infoPopupData: this.initialState().infoPopupData,
      layers: {
        baseLayer: this.layers().baseLayer,
        decollementLayer: null,
        earthquakesLayer: this.layers().earthquakesLayer,
        faultSectionLayer: null,
        hazardLayer: this.createHazardLayer(defaultTile, values.overlayOpacity),
        interfaceSectionsLayer: null,
        nshmBoundaryLayer: null,
        testSitesLayer: null,
        zoneSourcesLayer: null,
      },
    });

    if (hazardTiles.length > 0) {
      this.formGroup.controls.hasHazardTiles.enable();
    } else {
      this.formGroup.controls.hasHazardTiles.disable();
    }

    this.formGroup.patchValue({
      hasEarthquakesLayer: false,
      hasHazardTiles: false,
      hasTestSitesLayer: false,
      hazardTileImt: defaultTile?.imt,
      hazardTileReturnPeriod: defaultTile?.returnPeriod,
      hazardTileYear: defaultTile ? nshmYear(defaultTile?.nshm) : undefined,
    });

    this.callService();
  }

  /**
   * Check to see if feature type is included in the list of supported feature type to
   * enable or disable the control form field.
   *
   * @param featureType The feature type
   * @param featureTypes List of all feature types
   * @param control Feature type form state
   */
  sourceFeatureControl(
    featureType: FeatureType,
    featureTypes: string[],
    control: AbstractControl<boolean>,
  ): void {
    control.setValue(false);

    if (featureTypes.includes(featureType)) {
      control.enable();
    } else {
      control.disable();
    }
  }

  updateState(state: Partial<AppState>): void {
    const updatedState = {
      ...this.state(),
      ...state,
    };

    if (!deepEqual(updatedState, this.state())) {
      this.state.set({
        ...this.state(),
        ...state,
      });
    }
  }

  private callHazardTilesUsage(): Observable<void> {
    return this.http
      .get<ArcServiceResponse>(`${environment.webServices.hazardTiles}?f=pjson`)
      .pipe(
        map(response => {
          const hazardTiles = this.toHazardTiles(response);
          const controls = this.formGroup.controls;

          const hazardTilesForRegion = this.filterTilesByRegion(
            hazardTiles,
            this.formGroup.getRawValue(),
          );

          const defaultTile = [...hazardTilesForRegion].pop();

          if (hazardTilesForRegion.length > 0) {
            controls.hasHazardTiles.enable();
          } else {
            controls.hasHazardTiles.disable();
          }

          this.formGroup.patchValue({
            hasHazardTiles: false,
            hazardTileImt: defaultTile?.imt,
            hazardTileReturnPeriod: defaultTile?.returnPeriod,
            hazardTileYear: nshmYear(defaultTile?.nshm),
          });

          this.updateState({
            hazardTiles,
            layers: {
              ...this.layers(),
              hazardLayer: this.createHazardLayer(
                defaultTile,
                this.formGroup.getRawValue().overlayOpacity,
              ),
            },
          });

          this.formGroup.controls.model.setValue(
            this.formGroup.getRawValue().model,
          );
        }),
      );
  }

  private callTreesUsage(): Observable<void> {
    return this.hazardService
      .dynamicNshms$<SourceLogicTreesMetadata>(
        `${this.nshmpHazWs.url}${this.nshmsEndpoint}`,
        environment.webServices.nshmpHazWs.services.curveServices.trees,
      )
      .pipe(
        map(({models, nshmServices, usageResponses}) => {
          this.updateState({
            availableModels: models,
            nshmServices,
            treesUsageResponses: usageResponses,
          });
        }),
        catchError((error: Error) => {
          return this.nshmpService.throwError$(error);
        }),
      );
  }

  /**
   * Default Leaflemt map options.
   */
  private defaultMapOptions(): L.MapOptions {
    /** North-east default map bounds */
    const northEastBounds = new L.LatLng(85, Infinity);
    /** South-west default map bounds */
    const southWestBounds = new L.LatLng(-85, -Infinity);
    /** Default map bounds */
    const maxBounds = new L.LatLngBounds(southWestBounds, northEastBounds);

    return {
      center: L.latLng(40, -105),
      maxBounds,
      minZoom: 3,
      renderer: L.canvas({tolerance: 5}),
      zoom: 4,
    };
  }

  /**
   * Dim all features in a layer.
   *
   * @param layer The layer to dim
   */
  private dimLayer(layer: L.GeoJSON): void {
    if (layer === null) {
      return;
    }

    const opacity = 0.3;

    layer.getLayers().forEach((feature: L.GeoJSON | L.Marker) => {
      if (feature instanceof L.Marker) {
        feature.setOpacity(opacity);
      } else {
        feature.setStyle({
          fillOpacity: opacity,
          opacity,
        });
      }
    });
  }

  private earthquakesToLayer(
    earthquakes: GeoJSON.FeatureCollection<GeoJSON.Point>,
  ): L.GeoJSON {
    const features = earthquakes.features.filter(feature => {
      const coords = feature.geometry.coordinates;
      return this.bounds().contains(L.latLng(coords[1], coords[0]));
    });

    earthquakes = {
      ...earthquakes,
      features,
    };

    const earthquakeLayer = L.geoJSON<
      GeoJSON.FeatureCollection<GeoJSON.Point, EarthquakeFeatureProperties>
    >(earthquakes, {
      onEachFeature: (feature, layer: L.GeoJSON) => {
        const popupContent = 'Select for properties';

        layer.bindTooltip(popupContent, {
          offset: L.point(10, 0),
        });

        layer.on('click', event => {
          this.zone.run(() => {
            this.resetLayers();
            L.DomEvent.stopPropagation(event);

            layer.setStyle({
              fillColor: '#0ff',
            });

            this.updateState({
              earthquakeInfoPopupData: {
                feature: feature as GeoJSON.Feature<
                  GeoJSON.Point,
                  EarthquakeFeatureProperties
                >,
              },
              infoPopupData: null,
            });
          });
        });
      },
      pointToLayer: (
        feature: GeoJSON.Feature<GeoJSON.Point, EarthquakeFeatureProperties>,
        latlng,
      ) => {
        const layer = L.circle(latlng, {
          color: 'black',
          fill: true,
          fillColor: 'orange',
          fillOpacity: this.opacity,
          radius:
            (feature.properties.mag * 150000) /
            Math.pow(this.map().getZoom(), 2),
          weight: 1,
        });

        return layer;
      },
    });

    return earthquakeLayer;
  }

  /**
   * Returns the extended map bounds based on all layers.
   *
   * @param bounds The Leaflet map bounds
   * @param layers Leaflet layers
   */
  private extendBounds(bounds: L.LatLngBounds, layers: Layers): L.LatLngBounds {
    Object.values(layers)
      .filter(layer => layer instanceof L.GeoJSON)
      .filter(layer => layer !== layers.earthquakesLayer)
      .forEach((layer: L.GeoJSON) => {
        if (layer) {
          bounds = bounds.extend(layer.getBounds());
        }
      });

    return bounds;
  }

  /**
   * Returns a Leaflet layer of a feature type.
   *
   * @param fc The GeoJSON feature collection
   * @param featureType The feature type
   * @returns
   */
  private featureTypeToLayer(
    fc: GeoJSON.FeatureCollection,
    featureType: FeatureType,
  ): L.GeoJSON {
    let color = 'red';
    let weight = 2;

    switch (featureType) {
      case FeatureType.DECOLLEMENT:
        color = 'purple';
        break;
      case FeatureType.FAULT:
        color = 'red';
        weight = this.faultsWeight();
        break;
      case FeatureType.INTERFACE:
        color = 'green';
        break;
      case FeatureType.ZONE:
        color = 'blue';
        break;
      default:
        throw new Error(`Feature type [${featureType}] not allowed`);
    }

    const geoJsonLayer = L.geoJSON<GeoJSON.FeatureCollection>(fc, {
      onEachFeature: (feature, layer: L.GeoJSON) =>
        this.onEachFeatureSourceFeatures(layer, feature, featureType),
      style: {
        color,
        fillOpacity: this.opacity,
        opacity: this.opacity,
        weight,
      },
    });

    geoJsonLayer.eachLayer((layer: L.GeoJSON) => {
      layer.on('mouseover', event => {
        geoJsonLayer.resetStyle();
        geoJsonLayer.setStyle({
          weight:
            featureType === FeatureType.FAULT ? this.faultsWeight() : weight,
        });
        const target = event.target as L.GeoJSON;
        target.setStyle({
          fillOpacity: 1,
          opacity: 1,
          weight:
            featureType === FeatureType.FAULT
              ? this.faultsWeight() * 2
              : this.featuresHoverWeight,
        });
      });

      layer.on('mouseout', () => {
        if (!deepEqual(this.infoPopupData().feature, layer.toGeoJSON())) {
          geoJsonLayer.resetStyle();
          geoJsonLayer.setStyle({
            weight:
              featureType === FeatureType.FAULT ? this.faultsWeight() : weight,
          });
        }
      });
    });

    return geoJsonLayer;
  }

  private initialControlSet(
    control: FormControl<boolean>,
    defaultValue: boolean,
    queryValue?: string,
  ): void {
    if (control.enabled) {
      control.patchValue(
        nshmpUtils.queryParseBoolean(defaultValue, queryValue),
      );
    }
  }

  private initialFormSet(): void {
    const query = this.route.snapshot.queryParams as Query;
    const defaultValues = this.defaultFormValues();
    const controls = this.formGroup.controls;

    this.formGroup.patchValue({
      model: query?.model ?? defaultValues.model,
    });

    this.formGroup.patchValue({
      hasTestSitesLayer: nshmpUtils.queryParseBoolean(
        defaultValues.hasTestSitesLayer,
        query?.hasTestSitesLayer,
      ),
    });

    this.initialControlSet(
      controls.hasDecollementLayer,
      defaultValues.hasDecollementLayer,
      query?.hasDecollementLayer,
    );
    this.initialControlSet(
      controls.hasEarthquakesLayer,
      defaultValues.hasEarthquakesLayer,
      query?.hasEarthquakesLayer,
    );
    this.initialControlSet(
      controls.hasFaultSectionLayer,
      defaultValues.hasFaultSectionLayer,
      query?.hasFaultSectionLayer,
    );
    this.initialControlSet(
      controls.hasHazardTiles,
      defaultValues.hasHazardTiles,
      query?.hasHazardTiles,
    );
    this.initialControlSet(
      controls.hasInterfaceSectionsLayer,
      defaultValues.hasInterfaceSectionsLayer,
      query?.hasNshmBoundaryLayer,
    );
    this.initialControlSet(
      controls.hasNshmBoundaryLayer,
      defaultValues.hasNshmBoundaryLayer,
      query?.hasNshmBoundaryLayer,
    );
    this.initialControlSet(
      controls.hasZoneSourcesLayer,
      defaultValues.hasZoneSourcesLayer,
      query?.hasZoneSourcesLayer,
    );

    this.formGroup.valueChanges.subscribe(() => this.updateUrl());
    this.mapRedraw();
  }

  /**
   * Returns the bounds for a specific NSHM.
   *
   * @param model The NSHM
   */
  private modelBounds(model: NshmId): L.LatLngBounds {
    /** COUNS NSHM bounds */
    const conusBounds = new L.LatLngBounds(
      new L.LatLng(48.75, -68.8),
      new L.LatLng(25.75, -124.25),
    );

    const bounds: Partial<Record<NshmId, L.LatLngBounds>> = {
      ALASKA_2023: new L.LatLngBounds(
        new L.LatLng(71.3, -131.7),
        new L.LatLng(51.9, -176.7),
      ),
      CONUS_2018: conusBounds,
      CONUS_2023: conusBounds,
      HAWAII_2021: new L.LatLngBounds(
        new L.LatLng(22.22, -155.06),
        new L.LatLng(19.1, -160.2),
      ),
    };

    return bounds[model];
  }

  /**
   * Returns the `NshmId` from a string.
   *
   * @param nshm The NSHM string
   */
  private nshmIdFromString(nshm: string): NshmId {
    const nshmId = Object.values(NshmId).find(id => id.toString() === nshm);

    if (nshmId === undefined) {
      throw new Error(`NSHM ${nshm} not found`);
    }

    return nshmId;
  }

  /**
   * Create tooltip with GeoJSON properties.
   *
   * @param feature The GeoJSON feature
   * @param layer The Leaflet layer
   */
  private onEachFeatureSourceFeatures(
    layer: L.GeoJSON,
    feature: GeoJSON.Feature,
    featureType: FeatureType,
  ): void {
    const popupContent = 'Select for properties';

    layer.bindTooltip(popupContent, {
      offset: L.point(10, 0),
    });

    layer.on('click', event => {
      this.zone.run(() => {
        L.DomEvent.stopPropagation(event);
        this.resetLayers();

        this.updateState({
          earthquakeInfoPopupData: null,
          infoPopupData: {
            feature: feature as unknown as GeoJSON.Feature<
              GeoJSON.Geometry,
              FaultSectionProperties
            >,
            featureType,
          },
        });

        layer.setStyle({
          fillOpacity: 1,
          opacity: 1,
          weight:
            featureType === FeatureType.FAULT
              ? this.faultsWeight() * 2
              : this.featuresHoverWeight,
        });
      });
    });
  }

  private resetSourceFeatureControls(): void {
    const featureTypes = this.sourceFeaturesUsage().response.featureType.map(
      type => type.value,
    );

    const controls = this.formGroup.controls;

    this.sourceFeatureControl(
      FeatureType.DECOLLEMENT,
      featureTypes,
      controls.hasDecollementLayer,
    );
    this.sourceFeatureControl(
      FeatureType.FAULT,
      featureTypes,
      controls.hasFaultSectionLayer,
    );
    this.sourceFeatureControl(
      FeatureType.INTERFACE,
      featureTypes,
      controls.hasInterfaceSectionsLayer,
    );
    this.sourceFeatureControl(
      FeatureType.ZONE,
      featureTypes,
      controls.hasZoneSourcesLayer,
    );
  }

  /**
   * Convert ArcGIS service response to `HazardTile`s.
   *
   * Example JSON response:
   *  ```
   * {
   *  "currentVersion": 10.61,
   *   "services": [
   *   {
   *     "name": "haz/AK1hz050_1999",
   *     "type": "MapServer"
   *   }
   * }
   *  ```
   *
   * @param response The ArcGIS service response
   */
  private toHazardTiles(response: ArcServiceResponse): HazardTile[] {
    const services = response.services.filter(
      service => service.name.includes('hz') || service.name.includes('pga'),
    );

    const hazardTiles: HazardTile[] = [];

    // Example name: haz/USpga050_2008
    services.forEach(service => {
      const mapName = service.name.split('haz/').pop();
      const region = mapName.substring(0, 2);
      const imt = this.toImt(mapName.substring(2, 5));
      const returnPeriod = this.toReturnPeriod(mapName.substring(5, 8));
      const year = Number.parseInt(mapName.split('_').pop());
      const nshm = this.toNshmId(region, year);

      const hazardTile: HazardTile = {
        imt,
        mapName,
        nshm,
        returnPeriod,
      };

      hazardTiles.push(hazardTile);
    });

    return hazardTiles;
  }

  /**
   * Returns the `Imt` corresponding to the IMT string in the map name.
   *
   * @param imt The IMT string
   */
  private toImt(imt: string): Imt {
    switch (imt) {
      case 'pga':
        return Imt.PGA;
      case '5hz':
        return Imt.SA0P2;
      case '1hz':
        return Imt.SA1P0;
      default:
        throw new Error(`IMT [${imt}] not supported`);
    }
  }

  /**
   * Convert the region and year from the map name into a corresponding `NshmId`.
   * @param region The region from map name
   * @param year The year from map name
   * @returns
   */
  private toNshmId(region: string, year: number): NshmId {
    switch (region) {
      case 'AK':
        return this.nshmIdFromString(
          `${nshmRegion(NshmId.ALASKA_2023)}_${year}`,
        );
      case 'HI':
        return this.nshmIdFromString(
          `${nshmRegion(NshmId.HAWAII_2021)}_${year}`,
        );
      case 'US':
        return this.nshmIdFromString(
          `${nshmRegion(NshmId.CONUS_2023)}_${year}`,
        );
      default:
        throw new Error(`Region [${region}] year [${year}] not supported`);
    }
  }

  /**
   * Returns the `ReturnPeriod` from the corresponding string.
   *
   * @param returnPeriod The return period string from ArcGIS map name
   */
  private toReturnPeriod(returnPeriod: string): ReturnPeriod {
    switch (returnPeriod) {
      // 10% in 50
      case '050':
        return ReturnPeriod.RP_475;
      // 2% in 50
      case '250':
        return ReturnPeriod.RP_2475;
      default:
        throw new Error(`Return period [${returnPeriod}] not supported`);
    }
  }

  private updateUrl(): void {
    this.location.replaceState(
      apps().source.data.routerLink,
      new HttpParams().appendAll(this.formGroup.getRawValue()).toString(),
    );
  }
}

results matching ""

    No results matching ""