File

services/app.service.ts

Extends

DynamicHazardControlForm

Index

Properties

Properties

imt
imt: Imt
Type : Imt
import {Location as LocationService} from '@angular/common';
import {HttpClient, HttpParams} from '@angular/common/http';
import {computed, DestroyRef, Injectable, Signal, signal} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {AbstractControl, FormBuilder, FormGroup, Validators} from '@angular/forms';
import {MatDialogRef} from '@angular/material/dialog';
import {ActivatedRoute} from '@angular/router';
import {
  DynamicHazardControlForm,
  HazardPlots,
  HazardService,
  hazardUtils,
  ResponseSpectra,
} from '@ghsc/nshmp-lib-ng/hazard';
import {
  FormGroupControls,
  NshmpService,
  returnPeriodAltName,
  ServiceCallInfo,
} from '@ghsc/nshmp-lib-ng/nshmp';
import {NshmpPlot, NshmpPlotSettingFormGroup, plotUtils} from '@ghsc/nshmp-lib-ng/plot';
import {NshmpTemplateSpinnerComponent, SpinnerService} from '@ghsc/nshmp-template';
import {
  HazardCalcResponse,
  HazardRequestMetadata,
  HazardResponseData,
  HazardUsageResponse,
} from '@ghsc/nshmp-utils-ts/libs/nshmp-haz/www/hazard-service';
import {NshmMetadata} from '@ghsc/nshmp-utils-ts/libs/nshmp-haz/www/nshm-service';
import {Location} from '@ghsc/nshmp-utils-ts/libs/nshmp-lib/geo';
import {Imt, imtToPeriod, imtToString} from '@ghsc/nshmp-utils-ts/libs/nshmp-lib/gmm';
import {SourceType, sourceTypeToCapitalCase} from '@ghsc/nshmp-utils-ts/libs/nshmp-lib/model';
import {NshmId} from '@ghsc/nshmp-utils-ts/libs/nshmp-lib/nshm';
import {Parameter} from '@ghsc/nshmp-utils-ts/libs/nshmp-ws-utils/metadata';
import {PlotlyPlot} from '@ghsc/nshmp-utils-ts/libs/plotly';
import deepEqual from 'deep-equal';
import {PlotData} from 'plotly.js';
import {environment} from 'projects/nshmp-apps/src/environments/environment';
import {AppServiceModel} from 'projects/nshmp-apps/src/shared/models/app-service.model';
import {SharedService} from 'projects/nshmp-apps/src/shared/services/shared.service';
import {apps} from 'projects/nshmp-apps/src/shared/utils/applications.utils';
import {catchError} from 'rxjs/operators';

import {DynamicHazardQuery} from '../models/query.model';
import {AppState} from '../models/state.model';

export interface ControlForm extends DynamicHazardControlForm {
  imt: Imt;
}

enum SourcePlot {
  SOURCES = 'SOURCES',
}

const Plots = {...HazardPlots, ...SourcePlot};
type Plots = typeof Plots;

/**
 * Entrypoint to store for dynamic hazard application.
 */
@Injectable({
  providedIn: 'root',
})
export class AppService extends SharedService implements AppServiceModel<AppState, ControlForm> {
  /** nshmp-haz-ws web config */
  private nshmpHazWs = environment.webServices.nshmpHazWs;
  /** Hazard endpoint */
  private serviceEndpoint = this.nshmpHazWs.services.curveServices.hazard;
  /** Localhost url, cors issue with localhost:8008 must use ip */
  private localhostUrl = 'http://127.0.0.1:8080';

  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 route: ActivatedRoute,
    private location: LocationService,
    private http: HttpClient,
    private destroyRef: DestroyRef,
  ) {
    super();
    this.addValidators();
  }

  /**
   * Returns the available models.
   */
  get availableModels(): Signal<Parameter[]> {
    return computed(() => this.state().availableModels);
  }

  /**
   * Returns the hazard plot data.
   */
  get hazardPlotData(): Signal<PlotlyPlot> {
    return computed(() => this.hazardPlotState().plotData);
  }

  /**
   * Returns the hazard plot settings form.
   */
  get hazardPlotSettings(): Signal<FormGroup<NshmpPlotSettingFormGroup>> {
    return computed(() => this.hazardPlotState().settingsForm);
  }

  /**
   * Returns the hazard plot.
   */
  get hazardPlotState(): Signal<NshmpPlot> {
    return computed(() => this.state().plots.get(Plots.HAZARD));
  }

  /**
   * Returns the hazard plot data.
   */
  get sourcesPlotData(): Signal<PlotlyPlot> {
    return computed(() => this.sourcesPlotState().plotData);
  }

  /**
   * Returns the hazard plot settings form.
   */
  get sourcesPlotSettings(): Signal<FormGroup<NshmpPlotSettingFormGroup>> {
    return computed(() => this.sourcesPlotState().settingsForm);
  }

  /**
   * Returns the hazard plot.
   */
  get sourcesPlotState(): Signal<NshmpPlot> {
    return computed(() => this.state().plots.get(Plots.SOURCES));
  }

  /**
   * Returns the metadata of the NSHM observable.
   */
  get nshmService(): Signal<NshmMetadata> {
    return computed(() =>
      this.state().nshmServices.find(
        nshmService => nshmService.model === this.formGroup.getRawValue().model,
      ),
    );
  }

  get plots(): Signal<Map<string, NshmpPlot>> {
    return computed(() => this.state().plots);
  }

  /**
   * Returns the response spectra
   */
  get responseSpectra(): Signal<ResponseSpectra> {
    return computed(() => this.state().responseSpectra);
  }

  /**
   * Returns the service call info.
   */
  get serviceCallInfo(): Signal<ServiceCallInfo> {
    return computed(() => this.state().serviceCallInfo);
  }

  /**
   * Returns the disagg response.
   */
  get serviceResponse(): Signal<HazardCalcResponse> {
    return computed(() => this.state().serviceResponse);
  }

  /**
   * Returns the spectrum plot data.
   */
  get spectrumPlotData(): Signal<PlotlyPlot> {
    return computed(() => this.spectrumPlotState().plotData);
  }

  /**
   * Returns the spectrum plot settings form.
   */
  get spectrumPlotSettings(): Signal<FormGroup<NshmpPlotSettingFormGroup>> {
    return computed(() => this.spectrumPlotState().settingsForm);
  }

  /**
   * Returns the response spectrum plot.
   */
  get spectrumPlotState(): Signal<NshmpPlot> {
    return computed(() => this.state().plots.get(Plots.SPECTRUM));
  }

  /**
   * Return the usage for the selected model.
   */
  get usage(): Signal<HazardUsageResponse> {
    return computed(() => this.state().usageResponses?.get(this.formGroup.getRawValue().model));
  }

  addValidators(): void {
    const controls = this.formGroup.controls;

    this.addRequiredValidator(controls.latitude);
    this.addRequiredValidator(controls.longitude);
    this.addRequiredValidator(controls.model);
    this.addRequiredValidator(controls.vs30);
    this.addRequiredValidator(controls.returnPeriod);

    controls.latitude.addValidators(this.validateNan());
    controls.longitude.addValidators(this.validateNan());
  }

  /**
   * Call the hazard service.
   */
  callService(): void {
    const spinnerRef = this.spinnerService.show(
      `${SpinnerService.MESSAGE_SERVICE}
      <br>
      (Could take 30+ seconds)
      `,
    );
    const values = this.formGroup.getRawValue();

    const service = this.state().nshmServices.find(
      nshmService => nshmService.model === values.model,
    );
    const serviceUrl = `${service.url}${this.serviceEndpoint}`;
    const url = this.createServiceEndpoint(serviceUrl, values);

    this.nshmpService
      .callService$<HazardCalcResponse>(url)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .pipe(
        catchError((error: Error) => {
          spinnerRef.close();
          return this.nshmpService.throwError$(error);
        }),
      )
      .subscribe(serviceResponse => {
        spinnerRef.close();

        const responseSpectra = hazardUtils.responseSpectra(
          serviceResponse.response.hazardCurves,
          this.formGroup.getRawValue(),
        );

        this.updateState({
          responseSpectra,
          serviceCallInfo: {
            ...this.state().serviceCallInfo,
            serviceCalls: [url],
          },
          serviceResponse,
        });

        this.createPlots();
      });
  }

  createPlots(): void {
    const plots = this.responseToPlots(this.state(), this.formGroup);
    this.updateState({plots});
  }

  /**
   * Returns the default form values.
   */
  defaultFormValues(): ControlForm {
    return {
      ...hazardUtils.hazardDefaultFormValues(),
      imt: null,
      sourceType: sourceTypeToCapitalCase(SourceType.TOTAL),
      vs30: 760,
    };
  }

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

    if (this.nshmpHazWs.url.startsWith(this.localhostUrl)) {
      this.localInit(spinnerRef);
    } else {
      this.hazardService
        .dynamicNshms$<HazardRequestMetadata>(
          `${this.nshmpHazWs.url}${this.nshmpHazWs.services.nshms}`,
          this.serviceEndpoint,
        )
        .pipe(takeUntilDestroyed(this.destroyRef))
        .pipe(
          catchError((error: Error) => {
            spinnerRef.close();
            return this.nshmpService.throwError$(error);
          }),
        )
        .subscribe(({models, nshmServices, usageResponses}) => {
          this.onInit(models, nshmServices, usageResponses, spinnerRef);
        });
    }
  }

  /**
   * Hazard app initial state
   */
  initialState(): AppState {
    const usageResponses: Map<string, HazardUsageResponse> = new Map();
    usageResponses.set(NshmId.CONUS_2018, null);

    const plots = hazardUtils.hazardDefaultPlots();
    const sourcesPlot = plotUtils.defaultPlot({
      id: 'sources',
      title: 'Sources',
      xLabel: 'Ground Motion (g)',
      yLabel: 'Annual Frequency of Exceedance',
    });
    plots.set(Plots.SOURCES, {
      label: 'Sources',
      plotData: sourcesPlot,
      settingsForm: plotUtils.plotSettingsToFormGroup({
        config: sourcesPlot.config,
        layout: plotUtils.plotlyLayoutToSettings(sourcesPlot.layout),
      }),
    });

    return {
      availableModels: [],
      nshmServices: [],
      plots,
      responseSpectra: null,
      serviceCallInfo: {
        serviceCalls: [],
        serviceName: 'Dynamic Hazard Curves',
        usage: [],
      },
      serviceResponse: null,
      usageResponses,
    };
  }

  /**
   * Reset the control panel.
   */
  resetControlPanel(): void {
    this.formGroup.reset(this.defaultFormValues());
    this.resetState();
  }

  /**
   * Reset the plot settings.
   */
  resetSettings(): void {
    super.resetPlotSettings({
      currentPlots: this.state().plots,
      defaultPlots: this.initialState().plots,
    });
  }

  resetState(): void {
    this.updateState({
      plots: this.initialState().plots,
      serviceCallInfo: {
        ...this.state().serviceCallInfo,
        serviceCalls: [],
      },
      serviceResponse: null,
    });
    this.updateUsageUrl();
  }

  /**
   * Set the location form fields.
   *
   * @param location The location
   */
  setLocation(location: Location): void {
    this.formGroup.patchValue({
      latitude: location.latitude,
      longitude: location.longitude,
    });
  }

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

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

  private addRequiredValidator(control: AbstractControl): void {
    control.addValidators(control => Validators.required(control));
  }

  private createSourcesPlotData(
    responseData: HazardResponseData,
    plot: NshmpPlot,
    form: ControlForm,
  ): PlotlyPlot {
    const {imt} = form;

    if (imt === null || imt === undefined) {
      return plot.plotData;
    }

    const hazardCurve = responseData.hazardCurves.find(curve => curve.imt.value === imt.toString());

    if (hazardCurve === undefined) {
      return plot.plotData;
    }

    const plotlyData = hazardCurve.data.map((data, index) => {
      const xy = hazardUtils.updateXySequence(form, hazardUtils.cleanXySequence(data.values), imt);

      const plotlyData: Partial<PlotData> = {
        hovertemplate: '%{x} g, %{y} AFE',
        line: {
          color: hazardUtils.color(index, hazardCurve.data.length),
        },
        mode: 'lines+markers',
        name: data.component,
        uid: data.component,
        x: xy.xs,
        y: xy.ys,
      };

      return plotlyData;
    });

    const title = `${imtToString(imt)} Sources`;

    plot.settingsForm.patchValue({
      layout: {
        title: {
          text: title,
        },
      },
    });

    const metadata = responseData.metadata;
    const layout = plotUtils.updatePlotLabels({
      layout: plot.plotData.layout,
      title,
      xLabel: metadata.xlabel,
      yLabel: metadata.ylabel,
    });

    const mobileLayout = plotUtils.updatePlotLabels({
      layout: plot.plotData.mobileLayout,
      title,
      xLabel: metadata.xlabel,
      yLabel: metadata.ylabel,
    });

    return {
      config: {...plot.plotData.config},
      data: plotlyData,
      id: 'sources-by-imt',
      layout,
      mobileConfig: {...plot.plotData.mobileConfig},
      mobileLayout,
    };
  }

  /**
   * Create the hazard plot data.
   *
   * @param responseData The hazard response data
   * @param plot Plot
   * @param form Form values
   * @param returnPeriod Return period
   */
  private createHazardPlotData(
    responseData: HazardResponseData,
    plot: NshmpPlot,
    form: ControlForm,
    returnPeriod: number,
  ): PlotlyPlot {
    if (form.sourceType === null) {
      return plot.plotData;
    }

    const hazardCurves = responseData.hazardCurves.filter(
      response => response.imt.value !== Imt.PGV.toString(),
    );

    const data = hazardCurves.map((response, index) => {
      const imt = response.imt.value as Imt;

      const sourceTypeData = hazardUtils.getSourceTypeData(response.data, form.sourceType);
      const xy = hazardUtils.updateXySequence(
        form,
        hazardUtils.cleanXySequence(sourceTypeData.values),
        imt,
      );

      const plotlyData: Partial<PlotData> = {
        hovertemplate: '%{x} g, %{y} AFE',
        line: {
          color: hazardUtils.color(index, hazardCurves.length),
        },
        mode: 'lines+markers',
        name:
          imt === Imt.PGA || imt === Imt.PGV ? imt : `${imtToPeriod(imt)} s ${imt.substring(0, 2)}`,
        uid: imt.toString(),
        x: xy.xs,
        y: xy.ys,
      };
      return plotlyData;
    });

    const title = `Hazard Curves - ${form.sourceType}`;

    plot.settingsForm.patchValue({
      layout: {
        title: {
          text: title,
        },
      },
    });

    const metadata = responseData.metadata;
    const layout = plotUtils.updatePlotLabels({
      layout: plot.plotData.layout,
      title,
      xLabel: metadata.xlabel,
      yLabel: metadata.ylabel,
    });

    const mobileLayout = plotUtils.updatePlotLabels({
      layout: plot.plotData.mobileLayout,
      title,
      xLabel: metadata.xlabel,
      yLabel: metadata.ylabel,
    });

    return {
      config: {...plot.plotData.config},
      data: [this.returnPeriodPlotData(responseData, form.sourceType, returnPeriod), ...data],
      id: 'hazard-curves',
      layout,
      mobileConfig: {...plot.plotData.mobileConfig},
      mobileLayout,
    };
  }

  private createServiceEndpoint(serviceUrl: string, values: ControlForm): string {
    const {longitude, latitude, vs30} = values;
    return `${serviceUrl}/${longitude}/${latitude}/${vs30}`;
  }

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

    const formValues: ControlForm = {
      commonReturnPeriods: defaultValues.commonReturnPeriods,
      imt: [...this.usage().response.model.imts].shift()?.value as Imt,
      latitude:
        query.latitude !== undefined ? Number.parseFloat(query.latitude) : defaultValues.latitude,
      longitude:
        query.longitude !== undefined
          ? Number.parseFloat(query.longitude)
          : defaultValues.longitude,
      maxDirection:
        query.maxDirection !== undefined
          ? (JSON.parse(query.maxDirection) as boolean)
          : defaultValues.maxDirection,
      model: query.model !== undefined ? query.model : defaultValues.model,
      returnPeriod:
        query.returnPeriod !== undefined
          ? Number.parseInt(query.returnPeriod, 10)
          : defaultValues.returnPeriod,
      siteClass: query.siteClass !== undefined ? query.siteClass : defaultValues.siteClass,
      sourceType: query.sourceType !== undefined ? query.sourceType : defaultValues.sourceType,
      truncate:
        query.truncate !== undefined
          ? (JSON.parse(query.truncate) as boolean)
          : defaultValues.truncate,
      vs30: query.vs30 !== undefined ? Number.parseFloat(query.vs30) : defaultValues.vs30,
    };

    this.formGroup.patchValue(formValues);

    if (this.formGroup.valid) {
      this.nshmpService.selectPlotControl();
      this.callService();
    } else if (this.formGroup.getRawValue() !== defaultValues) {
      this.formGroup.markAsDirty();
    }

    this.formGroup.valueChanges
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.updateUrl());
  }

  private localInit(spinnerRef: MatDialogRef<NshmpTemplateSpinnerComponent>): void {
    const nshmId = this.defaultFormValues().model;

    const nshmService: NshmMetadata = {
      label: 'Local',
      model: nshmId,
      project: 'local',
      tag: 'local',
      test: null,
      url: this.localhostUrl,
      year: 0,
    };

    const usageResponses = new Map<string, HazardUsageResponse>();

    this.http
      .get<HazardUsageResponse>(`${nshmService.url}${this.serviceEndpoint}`)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .pipe(
        catchError((error: Error) => {
          spinnerRef.close();
          return this.nshmpService.throwError$(error);
        }),
      )
      .subscribe(usage => {
        usageResponses.set(nshmId, usage);

        const model: Parameter = {
          display: `Local: ${usage.response.model.name}`,
          value: nshmId,
        };

        this.onInit([model], [nshmService], usageResponses, spinnerRef);
      });
  }

  private onInit(
    availableModels: Parameter[],
    nshmServices: NshmMetadata[],
    usageResponses: Map<string, HazardUsageResponse>,
    spinnerRef: MatDialogRef<NshmpTemplateSpinnerComponent>,
  ): void {
    spinnerRef.close();

    this.updateState({
      availableModels,
      nshmServices,
      usageResponses,
    });

    this.updateUsageUrl();
    this.initialFormSet();
  }

  /**
   * Return the response spectrum plot data.
   *
   * @param spectra Response spectra
   * @param plot plot
   * @param returnPeriodValue Return period
   */
  private responseSpectrumPlotData(spectra: ResponseSpectra, plot: NshmpPlot): PlotlyPlot {
    if (spectra === null || spectra === undefined) {
      return;
    }

    const imts = spectra.imts;
    const lines: Partial<PlotData>[] = [];

    spectra.responseSpectrum.forEach((spectrum, iSpectra) => {
      const lineWidth = 2;
      const markerSize = 7;

      const color = hazardUtils.color(iSpectra, spectra.responseSpectrum.length);

      const spectraX: number[] = [];
      const spectraY: number[] = [];
      const scatters: Partial<PlotData>[] = [];

      for (let iSpectrum = 0; iSpectrum < spectrum.values.length; iSpectrum++) {
        const imt = imts[iSpectrum];

        if (imt === Imt.PGV) {
          continue;
        } else if (imt === Imt.PGA) {
          scatters.push({
            hovertemplate: '%{y} g',
            legendgroup: imt,
            marker: {
              color,
              size: markerSize,
              symbol: 'square',
            },
            mode: 'markers',
            name: imt,
            showlegend: false,
            uid: spectrum.returnPeriod.toString(),
            x: [imtToPeriod(imt)],
            y: [spectrum.values[iSpectrum]],
          });
        } else {
          spectraX.push(imtToPeriod(imt));
          spectraY.push(spectrum.values[iSpectrum]);
        }
      }

      lines.push({
        hovertemplate: '%{y} g',
        line: {
          color,
          width: lineWidth,
        },
        marker: {
          size: markerSize,
        },
        mode: 'lines+markers',
        name: returnPeriodAltName[spectrum.returnPeriod] ?? `${spectrum.returnPeriod} yr`,
        uid: spectrum.returnPeriod.toString(),
        x: spectraX,
        y: spectraY,
      });
      lines.push(...scatters);
    });

    const legendEntries: Partial<PlotData>[] = [
      {
        legendgroup: Imt.PGA,
        marker: {
          color: 'white',
          line: {
            width: 1.5,
          },
          size: 7,
          symbol: 'square',
        },
        mode: 'markers',
        name: 'PGA',
        x: [-1],
        y: [-1],
      },
    ];

    lines.push(...legendEntries);

    return {
      config: {...plot.plotData.config},
      data: lines,
      id: 'response-spectrum',
      layout: {...plot.plotData.layout},
      mobileConfig: {...plot.plotData.mobileConfig},
      mobileLayout: {...plot.plotData.mobileLayout},
    };
  }

  /**
   * Transform the service response the plot data.
   *
   * @param state The current state
   */
  private responseToPlots(
    state: AppState,
    formGroup: FormGroupControls<ControlForm>,
  ): Map<string, NshmpPlot> {
    if (state.serviceResponse === null || state.serviceResponse === undefined) {
      return state.plots;
    }

    const returnPeriod = formGroup.controls.returnPeriod.value;
    const responseData = state.serviceResponse.response;
    const plots = new Map<string, NshmpPlot>();
    const hazardPlot = state.plots.get(Plots.HAZARD);
    const spectrumPlot = state.plots.get(Plots.SPECTRUM);
    const sourcesPlot = state.plots.get(Plots.SOURCES);

    const hazardCurves = this.createHazardPlotData(
      responseData,
      hazardPlot,
      formGroup.getRawValue(),
      returnPeriod,
    );

    plots.set(Plots.HAZARD, {
      ...hazardPlot,
      plotData: hazardCurves,
    });

    const sources = this.createSourcesPlotData(
      responseData,
      sourcesPlot,
      this.formGroup.getRawValue(),
    );

    plots.set(Plots.SOURCES, {
      ...sourcesPlot,
      plotData: sources,
    });

    const spectrum = this.responseSpectrumPlotData(state.responseSpectra, spectrumPlot);

    plots.set(Plots.SPECTRUM, {
      ...spectrumPlot,
      plotData: spectrum,
    });

    return plots;
  }

  /**
   * Returns the plot data for the return period.
   *
   * @param responseData Hazard response data
   * @param sourceType Hazard source type
   * @param returnPeriod Retrun period
   */
  private returnPeriodPlotData(
    responseData: HazardResponseData,
    sourceType: string,
    returnPeriod: number,
  ): Partial<PlotData> {
    const data = responseData.hazardCurves
      .filter(hazard => hazard.imt.value !== Imt.PGV.toString())
      .map(hazard => hazardUtils.getSourceTypeData(hazard.data, sourceType).values);

    const xMin = Math.min(...data.map(xy => Math.min(...xy.xs)));
    const xMax = Math.max(...data.map(xy => Math.max(...xy.xs)));

    return {
      hoverinfo: 'none',
      line: {
        color: 'black',
      },
      mode: 'lines',
      name: `${returnPeriod} yr.`,
      x: [xMin, xMax],
      y: [1 / returnPeriod, 1 / returnPeriod],
    };
  }

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

  private updateUsageUrl() {
    const nshmService = this.state().nshmServices.find(
      nshm => nshm.model === this.formGroup.getRawValue().model,
    );

    if (nshmService !== undefined) {
      this.updateState({
        serviceCallInfo: {
          ...this.state().serviceCallInfo,
          usage: [nshmService.url],
        },
      });
    }
  }
}

results matching ""

    No results matching ""