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],
},
});
}
}
}