terriajs
Version:
Geospatial data visualization platform.
765 lines (682 loc) • 22.5 kB
text/typescript
import i18next from "i18next";
import { action, computed, runInAction } from "mobx";
import Mustache from "mustache";
import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import filterOutUndefined from "../../../Core/filterOutUndefined";
import isDefined from "../../../Core/isDefined";
import loadWithXhr from "../../../Core/loadWithXhr";
import TerriaError from "../../../Core/TerriaError";
import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin";
import TableMixin from "../../../ModelMixins/TableMixin";
import TableAutomaticStylesStratum from "../../../Table/TableAutomaticStylesStratum";
import TableColumnType from "../../../Table/TableColumnType";
import xml2json from "../../../ThirdParty/xml2json";
import SensorObservationServiceCatalogItemTraits from "../../../Traits/TraitsClasses/SensorObservationCatalogItemTraits";
import TableChartStyleTraits, {
TableChartLineStyleTraits
} from "../../../Traits/TraitsClasses/Table/ChartStyleTraits";
import TablePointSizeStyleTraits from "../../../Traits/TraitsClasses/Table/PointSizeStyleTraits";
import TableStyleTraits from "../../../Traits/TraitsClasses/Table/StyleTraits";
import CommonStrata from "../../Definition/CommonStrata";
import CreateModel from "../../Definition/CreateModel";
import createStratumInstance from "../../Definition/createStratumInstance";
import { BaseModel } from "../../Definition/Model";
import StratumFromTraits from "../../Definition/StratumFromTraits";
import StratumOrder from "../../Definition/StratumOrder";
import { SelectableDimension } from "../../SelectableDimensions/SelectableDimensions";
import Terria from "../../Terria";
import proxyCatalogItemUrl from "../proxyCatalogItemUrl";
interface GetFeatureOfInterestResponse {
featureMember?: FeatureMember[] | FeatureMember;
}
interface GetObservationResponse {
observationData?: ObservationData | ObservationData[];
}
interface FeatureMember {
MonitoringPoint?: {
shape?: { Point?: { pos?: string } };
identifier?: string;
name?: string;
type?: { ["xlink:href"]: string | undefined };
["gml:id"]: string | undefined;
};
}
interface TemplateContext {
action: string;
actionClass: string;
parameters: NameValue[];
}
interface NameValue {
name: string;
value: string;
}
interface ObservationData {
OM_Observation?: Observation;
}
interface Observation {
result?: {
MeasurementTimeseries?: { point?: ObservationPoint | ObservationPoint[] };
};
featureOfInterest: { [attr: string]: string | undefined };
}
interface ObservationPoint {
MeasurementTVP: MeasurementTimeValuePair;
}
interface MeasurementTimeValuePair {
time: Object | string;
value: Object | string;
}
StratumOrder.addLoadStratum(TableAutomaticStylesStratum.stratumName);
class SosAutomaticStylesStratum extends TableAutomaticStylesStratum {
constructor(readonly catalogItem: SensorObservationServiceCatalogItem) {
super(catalogItem);
}
duplicateLoadableStratum(
newModel: SensorObservationServiceCatalogItem
): this {
return new SosAutomaticStylesStratum(newModel) as this;
}
get activeStyle() {
return this.catalogItem.procedures[0]?.identifier;
}
get styles(): StratumFromTraits<TableStyleTraits>[] {
return this.catalogItem.procedures.map((p) => {
return createStratumInstance(TableStyleTraits, {
id: p.identifier,
title: p.title,
pointSize: createStratumInstance(TablePointSizeStyleTraits, {
pointSizeColumn: p.identifier
}),
// table style is hidden by default when the table uses only 1 color (https://github.com/TerriaJS/terriajs/blob/bbe8a11ae9bf6c0eb78c52d7b5c9b260d5ddc8cf/lib/Table/TableStyle.ts#L82)
// force hidden to false so that the frequency and procedure selector will always be shown
// Ideally we should rewrite frequency & procedure selector using selectable dimensions and stop using styles to display them.
hidden: false
});
});
}
get defaultChartStyle() {
const timeColumn = this.catalogItem.tableColumns.find(
(column) => column.type === TableColumnType.time
);
const valueColumn = this.catalogItem.tableColumns.find(
(column) => column.type === TableColumnType.scalar
);
if (timeColumn && valueColumn) {
return createStratumInstance(TableStyleTraits, {
chart: createStratumInstance(TableChartStyleTraits, {
xAxisColumn: timeColumn.name,
lines: [
createStratumInstance(TableChartLineStyleTraits, {
yAxisColumn: valueColumn.name
})
]
})
});
}
}
}
class GetFeatureOfInterestRequest {
constructor(
readonly catalogItem: SensorObservationServiceCatalogItem,
readonly requestTemplate: string
) {}
get url() {
return this.catalogItem.url;
}
get observedProperties() {
return filterOutUndefined(
this.catalogItem.observableProperties.map((p) => p.identifier)
);
}
get procedures() {
if (this.catalogItem.filterByProcedures) {
return filterOutUndefined(
this.catalogItem.procedures.map((p) => p.identifier)
);
}
}
async perform(): Promise<GetFeatureOfInterestResponse | undefined> {
if (this.url === undefined) {
return;
}
const templateContext = {
action: "GetFeatureOfInterest",
actionClass: "foiRetrieval",
parameters: convertObjectToNameValueArray({
observedProperty: this.observedProperties,
procedure: this.procedures
})
};
const response = await loadSoapBody(
this.catalogItem,
this.url,
this.requestTemplate,
templateContext
);
return response?.GetFeatureOfInterestResponse;
}
}
class GetObservationRequest {
constructor(
readonly catalogItem: SensorObservationServiceCatalogItem,
readonly foiIdentifier: string
) {}
get url() {
return this.catalogItem.url;
}
get requestTemplate() {
return (
this.catalogItem.requestTemplate ||
SensorObservationServiceCatalogItem.defaultRequestTemplate
);
}
get parameters() {
const foiIdentifier = this.catalogItem.chartFeatureOfInterestIdentifier;
const observableProperty = this.catalogItem.selectedObservable;
const procedure = this.catalogItem.selectedProcedure;
if (
foiIdentifier === undefined ||
procedure === undefined ||
observableProperty === undefined
) {
return;
}
return convertObjectToNameValueArray({
procedure: procedure.identifier,
observedProperty: observableProperty.identifier,
featureOfInterest: foiIdentifier
});
}
/**
* Return the Mustache template context "temporalFilters" for this item.
* If a "defaultDuration" parameter (eg. 60d or 12h) exists on either
* procedure or observableProperty, restrict to that duration from item.endDate.
* @param item This catalog item.
* @param procedure An element from the item.procedures array.
* @param observableProperty An element from the item.observableProperties array.
* @return An array of {index, startDate, endDate}, or undefined.
*/
get temporalFilters() {
const observableProperty = this.catalogItem.selectedObservable;
const procedure = this.catalogItem.selectedProcedure;
if (procedure === undefined || observableProperty === undefined) {
return;
}
const defaultDuration =
procedure.defaultDuration || observableProperty.defaultDuration;
// If the item has no endDate, use the current datetime (to nearest second).
const endDateIso8601 =
this.catalogItem.endDate || JulianDate.toIso8601(JulianDate.now(), 0);
if (defaultDuration) {
let startDateIso8601 = addDurationToIso8601(
endDateIso8601,
"-" + defaultDuration
);
// This is just a string-based comparison, so timezones could make it up to 1 day wrong.
// That much error is fine here.
if (
this.catalogItem.startDate &&
startDateIso8601 < this.catalogItem.startDate
) {
startDateIso8601 = this.catalogItem.startDate;
}
return [
{ index: 1, startDate: startDateIso8601, endDate: endDateIso8601 }
];
} else {
// If there is no procedure- or property-specific duration, use the item's start and end dates, if any.
if (this.catalogItem.startDate) {
return [
{
index: 1,
startDate: this.catalogItem.startDate,
endDate: endDateIso8601
}
];
}
}
}
async perform(): Promise<GetObservationResponse | undefined> {
if (this.url === undefined || this.parameters === undefined) {
return;
}
const templateContext = {
action: "GetObservation",
actionClass: "core",
parameters: this.parameters,
temporalFilters: this.temporalFilters
};
const response = await loadSoapBody(
this.catalogItem,
this.url,
this.requestTemplate,
templateContext
);
return response?.GetObservationResponse;
}
}
export default class SensorObservationServiceCatalogItem extends TableMixin(
CatalogMemberMixin(CreateModel(SensorObservationServiceCatalogItemTraits))
) {
static readonly type = "sos";
static defaultRequestTemplate = require("./SensorObservationServiceRequestTemplate.xml");
constructor(
id: string | undefined,
terria: Terria,
sourceReference?: BaseModel
) {
super(id, terria, sourceReference);
this.strata.set(
TableAutomaticStylesStratum.stratumName,
new SosAutomaticStylesStratum(this)
);
}
get type() {
return "sos";
}
protected async forceLoadMetadata() {}
protected async forceLoadTableData() {
if (this.showAsChart === true) {
return this.loadChartData();
} else {
return this.loadFeaturesData();
}
}
get cacheDuration(): string {
if (isDefined(super.cacheDuration)) {
return super.cacheDuration;
}
return "0d";
}
private async loadFeaturesData() {
const request = new GetFeatureOfInterestRequest(
this,
this.requestTemplate ||
SensorObservationServiceCatalogItem.defaultRequestTemplate
);
const response = await request.perform();
if (response === undefined) {
return [];
}
const itemName = runInAction(() => this.name || "");
if (response.featureMember === undefined) {
throw new TerriaError({
sender: this,
title: itemName,
message: i18next.t("models.sensorObservationService.unknownFormat")
});
}
let featureMembers = Array.isArray(response.featureMember)
? response.featureMember
: [response.featureMember];
const whiteList = runInAction(() => this.stationIdWhitelist);
if (whiteList) {
featureMembers = featureMembers.filter(
(m) =>
m.MonitoringPoint?.identifier &&
whiteList.indexOf(String(m.MonitoringPoint.identifier)) >= 0
);
}
const blackList = runInAction(() => this.stationIdBlacklist);
if (blackList) {
featureMembers = featureMembers.filter(
(m) =>
m.MonitoringPoint &&
blackList.indexOf(String(m.MonitoringPoint.identifier)) < 0
);
}
const identifierCols = ["identifier"];
const latCols = ["lat"];
const lonCols = ["lon"];
const nameCols = ["name"];
const idCols = ["id"];
const typeCols = ["type"];
const chartCols = ["chart"];
featureMembers.forEach((member) => {
const pointShape = member.MonitoringPoint?.shape?.Point;
if (!pointShape) {
throw new DeveloperError(
"Non-point feature not shown. You may want to implement `representAsGeoJson`. " +
JSON.stringify(pointShape)
);
}
if (!member.MonitoringPoint) return;
if (!pointShape.pos?.split) return;
if (!member.MonitoringPoint.identifier) return;
const [lat, lon] = pointShape.pos.split(" ");
const identifier = member.MonitoringPoint.identifier;
const name = member.MonitoringPoint.name;
const id = member.MonitoringPoint["gml:id"];
const type = member.MonitoringPoint.type?.["xlink:href"];
const chart = createChartColumn(identifier, name);
identifierCols.push(identifier);
latCols.push(lat);
lonCols.push(lon);
nameCols.push(name || "");
idCols.push(id || "");
typeCols.push(type || "");
chartCols.push(chart);
});
return [
identifierCols,
latCols,
lonCols,
nameCols,
idCols,
typeCols,
chartCols
];
}
private async loadChartData() {
const foiIdentifier = this.chartFeatureOfInterestIdentifier;
if (foiIdentifier === undefined) {
return [];
}
const request = new GetObservationRequest(this, foiIdentifier);
const response = await request.perform();
if (response === undefined) {
return [];
}
return runInAction(() => {
const procedure = this.selectedProcedure!;
const observableProperty = this.selectedObservable!;
const datesCol = ["date"];
const valuesCol = ["values"];
const observationsCol = ["observations"];
const identifiersCol = ["identifiers"];
const proceduresCol = [this.proceduresName];
const observedPropertiesCol = [this.observablePropertiesName];
const addObservationToColumns = (observation: Observation) => {
let points = observation?.result?.MeasurementTimeseries?.point;
if (!points) return;
if (!Array.isArray(points)) points = [points];
var measurements = points.map((point) => point.MeasurementTVP); // TVP = Time value pairs, I think.
var featureIdentifier =
observation.featureOfInterest["xlink:href"] || "";
datesCol.push(
...measurements.map((measurement) =>
typeof measurement.time === "object" ? "" : measurement.time
)
);
valuesCol.push(
...measurements.map((measurement) =>
typeof measurement.value === "object" ? "" : measurement.value
)
);
identifiersCol.push(...measurements.map((_) => featureIdentifier));
proceduresCol.push(
...measurements.map((_) => procedure.identifier || "")
);
observedPropertiesCol.push(
...measurements.map((_) => observableProperty.identifier || "")
);
};
const observationData =
response.observationData === undefined ||
Array.isArray(response.observationData)
? response.observationData
: [response.observationData];
if (!observationData) {
return [];
}
const observations = observationData.map((o) => o.OM_Observation);
observations.forEach((observation) => {
if (observation) {
addObservationToColumns(observation);
}
});
runInAction(() => {
// Set title for values column
const valueColumn = this.addObject(
CommonStrata.defaults,
"columns",
"values"
);
valueColumn?.setTrait(CommonStrata.defaults, "name", "values");
valueColumn?.setTrait(CommonStrata.defaults, "title", this.valueTitle);
});
return [
datesCol,
valuesCol,
observationsCol,
identifiersCol,
proceduresCol,
observedPropertiesCol
];
});
}
get valueTitle() {
if (
this.selectedObservable === undefined ||
this.selectedProcedure === undefined
) {
return;
}
const units = this.selectedObservable.units || this.selectedProcedure.units;
const valueTitle =
this.selectedObservable.title +
" " +
this.selectedProcedure.title +
(units !== undefined ? " (" + units + ")" : "");
return valueTitle;
}
get selectableDimensions() {
return filterOutUndefined([
// Filter out proceduresSelector - as it duplicates TableMixin.styleDimensions
...super.selectableDimensions.filter(
(dim) => dim.id !== this.proceduresSelector?.id
),
this.proceduresSelector,
this.observablesSelector
]);
}
get proceduresSelector(): SelectableDimension | undefined {
const proceduresSelector = super.styleDimensions;
if (proceduresSelector === undefined) return;
const item = this;
return {
...proceduresSelector,
get name(): string {
return item.proceduresName;
}
};
}
get observablesSelector(): SelectableDimension | undefined {
if (this.mapItems.length === 0) {
return;
}
const item = this;
return {
get id(): string {
return "observables";
},
get name(): string {
return item.observablePropertiesName;
},
get options() {
return filterOutUndefined(
item.observableProperties.map((p) => {
if (p.identifier && p.title) {
return {
id: p.identifier,
name: p.title || p.identifier
};
}
})
);
},
get selectedId(): string | undefined {
return item.selectedObservableId;
},
setDimensionValue(stratumId: string, observableId: string | undefined) {
item.setTrait(stratumId, "selectedObservableId", observableId);
}
};
}
get selectedObservableId() {
return (
super.selectedObservableId || this.observableProperties[0]?.identifier
);
}
get selectedObservable() {
return this.observableProperties.find(
(p) => p.identifier === this.selectedObservableId
);
}
get selectedProcedure() {
return this.procedures.find(
(p) => p.identifier === this.activeTableStyle.id
);
}
}
function createChartColumn(
identifier: string,
name: string | undefined
): string {
const nameAttr = name == undefined ? "" : `name="${name}"`;
// The API that provides the chart data is a SOAP API, and the download button is essentially just a link, so when you click it you get an error page.
// can-download="false" will disable this broken download button.
return `<sos-chart identifier="${identifier}" ${nameAttr} can-download="false"></sos-chart>`;
}
async function loadSoapBody(
item: SensorObservationServiceCatalogItem,
url: string,
requestTemplate: string,
templateContext: TemplateContext
): Promise<any> {
const requestXml = Mustache.render(requestTemplate, templateContext);
const responseXml = await loadWithXhr({
url: proxyCatalogItemUrl(item, url),
responseType: "document",
method: "POST",
overrideMimeType: "text/xml",
data: requestXml,
headers: { "Content-Type": "application/soap+xml" }
});
if (responseXml === undefined) {
return;
}
var json = xml2json(responseXml);
if (json.Exception) {
var errorMessage = i18next.t(
"models.sensorObservationService.unknownError"
);
if (json.Exception.ExceptionText) {
errorMessage = i18next.t(
"models.sensorObservationService.exceptionMessage",
{ exceptionText: json.Exception.ExceptionText }
);
}
throw new TerriaError({
sender: item,
title: runInAction(() => item.name || ""),
message: errorMessage
});
}
if (json.Body === undefined) {
throw new TerriaError({
sender: item,
title: runInAction(() => item.name || ""),
message: i18next.t("models.sensorObservationService.missingBody")
});
}
return json.Body;
}
/**
* Adds a period to an iso8601-formatted date.
* Periods must be (positive or negative) numbers followed by a letter:
* s (seconds), h (hours), d (days), y (years).
* To avoid confusion between minutes and months, do not use m.
* @param dateIso8601 The date in ISO8601 format.
* @param durationString The duration string, in the format described.
* @return A date string in ISO8601 format.
*/
function addDurationToIso8601(
dateIso8601: string,
durationString: string
): string {
const duration = parseFloat(durationString);
if (isNaN(duration) || duration === 0) {
throw new DeveloperError("Bad duration " + durationString);
}
const scratchJulianDate = new JulianDate();
let julianDate = JulianDate.fromIso8601(dateIso8601);
const units = durationString.slice(durationString.length - 1);
switch (units) {
case "s":
julianDate = JulianDate.addSeconds(
julianDate,
duration,
scratchJulianDate
);
break;
case "h":
julianDate = JulianDate.addHours(julianDate, duration, scratchJulianDate);
break;
case "d":
// Use addHours on 24 * numdays - on my casual reading of addDays, it needs an integer.
julianDate = JulianDate.addHours(
julianDate,
duration * 24,
scratchJulianDate
);
break;
case "y":
const days = Math.round(duration * 365);
julianDate = JulianDate.addDays(julianDate, days, scratchJulianDate);
break;
default:
throw new DeveloperError(
'Unknown duration type "' + durationString + '" (use s, h, d or y)'
);
}
return JulianDate.toIso8601(julianDate);
}
/**
* Converts parameters {x: 'y'} into an array of {name: 'x', value: 'y'} objects.
* Converts {x: [1, 2, ...]} into multiple objects:
* {name: 'x', value: 1}, {name: 'x', value: 2}, ...
* @param parameters eg. {a: 3, b: [6, 8]}
* @return eg. [{name: 'a', value: 3}, {name: 'b', value: 6}, {name: 'b', value: 8}]
*/
function convertObjectToNameValueArray(parameters: any): NameValue[] {
return Object.keys(parameters).reduce((result, key) => {
var values = parameters[key];
if (!Array.isArray(values)) {
values = [values];
}
if (values.length === 0) return result;
return result.concat(
filterOutUndefined(
values.map((value: string | undefined) => {
return value === undefined
? undefined
: {
name: key,
value: value
};
})
)
);
}, []);
}