terriajs
Version:
Geospatial data visualization platform.
880 lines (787 loc) • 29 kB
text/typescript
import i18next from "i18next";
import {
action,
computed,
isObservableArray,
observable,
runInAction
} from "mobx";
import { createTransformer, ITransformer } from "mobx-utils";
import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource";
import DataSource from "terriajs-cesium/Source/DataSources/DataSource";
import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider";
import { ChartPoint } from "../Charts/ChartData";
import getChartColorForId from "../Charts/getChartColorForId";
import Constructor from "../Core/Constructor";
import filterOutUndefined from "../Core/filterOutUndefined";
import flatten from "../Core/flatten";
import isDefined from "../Core/isDefined";
import { JsonObject } from "../Core/Json";
import { isLatLonHeight } from "../Core/LatLonHeight";
import TerriaError from "../Core/TerriaError";
import ConstantColorMap from "../Map/ColorMap/ConstantColorMap";
import RegionProvider from "../Map/Region/RegionProvider";
import RegionProviderList from "../Map/Region/RegionProviderList";
import CommonStrata from "../Models/Definition/CommonStrata";
import Model from "../Models/Definition/Model";
import updateModelFromJson from "../Models/Definition/updateModelFromJson";
import TerriaFeature from "../Models/Feature/Feature";
import FeatureInfoContext from "../Models/Feature/FeatureInfoContext";
import SelectableDimensions, {
SelectableDimension,
SelectableDimensionEnum,
SelectableDimensionGroup
} from "../Models/SelectableDimensions/SelectableDimensions";
import ViewingControls, { ViewingControl } from "../Models/ViewingControls";
import * as SelectableDimensionWorkflow from "../Models/Workflows/SelectableDimensionWorkflow";
import TableStylingWorkflow from "../Models/Workflows/TableStylingWorkflow";
import Icon from "../Styled/Icon";
import createLongitudeLatitudeFeaturePerId from "../Table/createLongitudeLatitudeFeaturePerId";
import createLongitudeLatitudeFeaturePerRow from "../Table/createLongitudeLatitudeFeaturePerRow";
import createRegionMappedImageryProvider from "../Table/createRegionMappedImageryProvider";
import TableColumn from "../Table/TableColumn";
import TableColumnType from "../Table/TableColumnType";
import { tableFeatureInfoContext } from "../Table/tableFeatureInfoContext";
import TableFeatureInfoStratum from "../Table/TableFeatureInfoStratum";
import { TableAutomaticLegendStratum } from "../Table/TableLegendStratum";
import TableStyle from "../Table/TableStyle";
import TableTraits from "../Traits/TraitsClasses/Table/TableTraits";
import CatalogMemberMixin from "./CatalogMemberMixin";
import { calculateDomain, ChartAxis, ChartItem } from "./ChartableMixin";
import DiscretelyTimeVaryingMixin, {
DiscreteTimeAsJS
} from "./DiscretelyTimeVaryingMixin";
import ExportableMixin, { ExportData } from "./ExportableMixin";
import { ImageryParts } from "./MappableMixin";
function TableMixin<T extends Constructor<Model<TableTraits>>>(Base: T) {
abstract class TableMixin
extends ExportableMixin(
DiscretelyTimeVaryingMixin(CatalogMemberMixin(Base))
)
implements SelectableDimensions, ViewingControls, FeatureInfoContext
{
/**
* The default {@link TableStyle}, which is used for styling
* only when there are no styles defined.
*/
readonly defaultTableStyle: TableStyle;
constructor(...args: any[]) {
super(...args);
// Create default TableStyle and set TableAutomaticLegendStratum
this.defaultTableStyle = new TableStyle(this);
if (
this.strata.get(TableAutomaticLegendStratum.stratumName) === undefined
) {
runInAction(() => {
this.strata.set(
TableAutomaticLegendStratum.stratumName,
TableAutomaticLegendStratum.load(this)
);
});
}
// Create TableFeatureInfoStratum
if (this.strata.get(TableFeatureInfoStratum.stratumName) === undefined) {
runInAction(() => {
this.strata.set(
TableFeatureInfoStratum.stratumName,
TableFeatureInfoStratum.load(this)
);
});
}
}
get hasTableMixin() {
return true;
}
// Always use the getter and setter for this
protected _dataColumnMajor: string[][] | undefined;
/**
* The list of region providers to be used with this table.
*/
regionProviderLists: RegionProviderList[] | undefined;
/**
* The raw data table in column-major format, i.e. the outer array is an
* array of columns.
*/
get dataColumnMajor(): string[][] | undefined {
const dataColumnMajor = this._dataColumnMajor;
if (
this.removeDuplicateRows &&
dataColumnMajor !== undefined &&
dataColumnMajor.length >= 1
) {
// De-duplication is slow and memory expensive, so should be avoided if possible.
const rowsToRemove = new Set();
const seenRows = new Set();
for (let i = 0; i < dataColumnMajor[0].length; i++) {
const row = dataColumnMajor.map((col) => col[i]).join();
if (seenRows.has(row)) {
// Mark row for deletion
rowsToRemove.add(i);
} else {
seenRows.add(row);
}
}
if (rowsToRemove.size > 0) {
return dataColumnMajor.map((col) =>
col.filter((cell, idx) => !rowsToRemove.has(idx))
);
}
}
return dataColumnMajor;
}
set dataColumnMajor(newDataColumnMajor: string[][] | undefined) {
this._dataColumnMajor = newDataColumnMajor;
}
/**
* Gets a {@link TableColumn} for each of the columns in the raw data.
*/
get tableColumns(): readonly TableColumn[] {
if (this.dataColumnMajor === undefined) {
return [];
}
return this.dataColumnMajor.map((_, i) => this.getTableColumn(i));
}
/**
* Gets a {@link TableStyle} for each of the {@link styles}. If there
* are no styles, returns an empty array.
*/
get tableStyles(): TableStyle[] {
if (this.styles === undefined) {
return [];
}
return this.styles.map((_, i) => this.getTableStyle(i));
}
/**
* Gets the active {@link TableStyle}, which is the item from {@link #tableStyles}
* with an ID that matches {@link #activeStyle}, if any.
*/
get activeTableStyle(): TableStyle {
const activeStyle = this.activeStyle;
if (activeStyle === undefined) {
return this.defaultTableStyle;
}
let ret = this.tableStyles.find((style) => style.id === this.activeStyle);
if (ret === undefined) {
return this.defaultTableStyle;
}
return ret;
}
get xColumn(): TableColumn | undefined {
return this.activeTableStyle.xAxisColumn;
}
get yColumns(): TableColumn[] {
const lines = this.activeTableStyle.chartTraits.lines;
return filterOutUndefined(
lines.map((line) => this.findColumnByName(line.yAxisColumn))
);
}
get _canExportData() {
return isDefined(this.dataColumnMajor);
}
protected async _exportData(): Promise<ExportData | undefined> {
if (isDefined(this.dataColumnMajor)) {
// I am assuming all columns have the same length -> so use first column
let csvString = this.dataColumnMajor[0]
.map((row, rowIndex) =>
this.dataColumnMajor!.map((col) => col[rowIndex]).join(",")
)
.join("\n");
// Make sure we have .csv file extension
let name = this.name || this.uniqueId || "data.csv";
if (!/(\.csv\b)/i.test(name)) {
name = `${name}.csv`;
}
return {
name: (this.name || this.uniqueId)!,
file: new Blob([csvString])
};
}
throw new TerriaError({
sender: this,
message: "No data available to download."
});
}
get disableZoomTo() {
// Disable zoom if only showing imagery parts (eg region mapping) and no rectangle is defined
if (
!this.mapItems.find(
(m) => m instanceof DataSource || m instanceof CustomDataSource
) &&
!isDefined(this.cesiumRectangle)
) {
return true;
}
return super.disableZoomTo;
}
/** Is showing regions (instead of points) */
get showingRegions() {
return (
this.regionMappedImageryParts &&
this.mapItems[0] === this.regionMappedImageryParts
);
}
/**
* Gets the items to show on the map.
*/
get mapItems(): (DataSource | ImageryParts)[] {
// Wait for activeTableStyle to be ready
if (
this.dataColumnMajor?.length === 0 ||
!this.activeTableStyle.ready ||
this.isLoadingMapItems
)
return [];
const numRegions =
this.activeTableStyle.regionColumn?.valuesAsRegions?.uniqueRegionIds
?.length ?? 0;
// Estimate number of points based off number of rowGroups
const numPoints = this.activeTableStyle.isPoints()
? this.activeTableStyle.rowGroups.length
: 0;
// If we have more points than regions OR we have points are are using a ConstantColorMap - show points instead of regions
// (Using ConstantColorMap with regions will result in all regions being the same color - which isn't useful)
if (
(numPoints > 0 &&
this.activeTableStyle.colorMap instanceof ConstantColorMap) ||
numPoints > numRegions
) {
const pointsDataSource = this.createLongitudeLatitudeDataSource(
this.activeTableStyle
);
// Make sure there are actually more points than regions
if (
pointsDataSource &&
pointsDataSource.entities.values.length > numRegions
)
return [pointsDataSource];
}
if (this.regionMappedImageryParts) return [this.regionMappedImageryParts];
return [];
}
// regionMappedImageryParts and regionMappedImageryProvider are split up like this so that we aren't re-creating the imageryProvider if things like `opacity` and `show` change
get regionMappedImageryParts() {
if (!this.regionMappedImageryProvider) return;
return {
imageryProvider: this.regionMappedImageryProvider,
alpha: this.opacity,
show: this.show,
clippingRectangle: this.cesiumRectangle
};
}
get regionMappedImageryProvider() {
return this.createRegionMappedImageryProvider({
style: this.activeTableStyle,
currentTime: this.currentDiscreteJulianDate
});
}
/**
* Try to resolve `regionType` to a region provider (this will also match against region provider aliases)
*/
matchRegionProvider(regionType?: string): RegionProvider | undefined {
if (!isDefined(regionType)) return;
const matchingRegionProviders = this.regionProviderLists?.map(
(regionProviderList) =>
regionProviderList?.getRegionDetails(
[regionType],
undefined,
undefined
)
);
// Return first regionProviderList with it's first match
// Note: a regionProviderList may have multiple matches - we could improve which one it selects
return matchingRegionProviders?.find(
(match) => match && match.length > 0
)?.[0].regionProvider;
}
/**
* Gets the items to show on a chart.
*
*/
private get tableChartItems(): ChartItem[] {
const style = this.activeTableStyle;
if (style === undefined || !style.isChart()) {
return [];
}
const xColumn = style.xAxisColumn;
const lines = style.chartTraits.lines;
if (xColumn === undefined || lines.length === 0) {
return [];
}
const xValues: readonly (Date | number | null)[] =
xColumn.type === TableColumnType.time
? xColumn.valuesAsDates.values
: xColumn.valuesAsNumbers.values;
const xAxis: ChartAxis = {
scale: xColumn.type === TableColumnType.time ? "time" : "linear",
units: xColumn.units
};
return filterOutUndefined(
lines.map((line) => {
const yColumn = this.findColumnByName(line.yAxisColumn);
if (yColumn === undefined) {
return undefined;
}
const yValues = yColumn.valuesAsNumbers.values;
const points: ChartPoint[] = [];
for (let i = 0; i < xValues.length; ++i) {
const x = xValues[i];
const y = yValues[i];
if (x === null || y === null) {
continue;
}
points.push({ x, y });
}
if (points.length <= 1) return;
const colorId = `color-${this.uniqueId}-${this.name}-${yColumn.name}`;
return {
item: this,
name: line.name ?? yColumn.title,
categoryName: this.name,
key: `key${this.uniqueId}-${this.name}-${yColumn.name}`,
type: this.chartType ?? "line",
glyphStyle: this.chartGlyphStyle ?? "circle",
xAxis,
points,
domain: calculateDomain(points),
units: yColumn.units,
isSelectedInWorkbench: line.isSelectedInWorkbench,
showInChartPanel: this.show && line.isSelectedInWorkbench,
updateIsSelectedInWorkbench: (isSelected: boolean) => {
runInAction(() => {
line.setTrait(
CommonStrata.user,
"isSelectedInWorkbench",
isSelected
);
});
},
getColor: () => {
return line.color || getChartColorForId(colorId);
},
pointOnMap: isLatLonHeight(this.chartPointOnMap)
? this.chartPointOnMap
: undefined
};
})
);
}
get chartItems() {
// Wait for activeTableStyle to be ready
if (!this.activeTableStyle.ready || this.isLoadingMapItems) return [];
return filterOutUndefined([
// If time-series region mapping - show time points chart
this.activeTableStyle.isRegions() && this.discreteTimes?.length
? this.momentChart
: undefined,
...this.tableChartItems
]);
}
get viewingControls(): ViewingControl[] {
return filterOutUndefined([
...super.viewingControls,
{
id: TableStylingWorkflow.type,
name: "Edit Style",
onClick: action((viewState) =>
SelectableDimensionWorkflow.runWorkflow(
viewState,
new TableStylingWorkflow(this)
)
),
icon: { glyph: Icon.GLYPHS.layers }
}
]);
}
get featureInfoContext(): (f: TerriaFeature) => JsonObject {
return tableFeatureInfoContext(this);
}
get selectableDimensions(): SelectableDimension[] {
return filterOutUndefined([
this.timeDisableDimension,
...super.selectableDimensions,
this.enableManualRegionMapping
? this.regionMappingDimensions
: undefined,
this.styleDimensions,
this.outlierFilterDimension
]);
}
/**
* Takes {@link TableStyle}s and returns a SelectableDimension which can be rendered in a Select dropdown
*/
get styleDimensions(): SelectableDimensionEnum | undefined {
if (this.mapItems.length === 0 && !this.enableManualRegionMapping) {
return;
}
return {
type: "select",
id: "activeStyle",
name: "Display Variable",
options: this.tableStyles
.filter((style) => !style.hidden || this.activeStyle === style.id)
.map((style) => {
return {
id: style.id,
name: style.title
};
}),
selectedId: this.activeStyle,
allowUndefined: this.showDisableStyleOption,
undefinedLabel: this.showDisableStyleOption
? i18next.t("models.tableData.styleDisabledLabel")
: undefined,
setDimensionValue: (stratumId: string, styleId) => {
this.setTrait(stratumId, "activeStyle", styleId);
}
};
}
/**
* Creates SelectableDimension for regionProviderList - the list of all available region providers.
* {@link TableTraits#enableManualRegionMapping} must be enabled.
*/
get regionProviderDimensions(): SelectableDimensionEnum | undefined {
const allRegionProviders = flatten(
this.regionProviderLists?.map((list) => list.regionProviders) ?? []
);
if (
allRegionProviders.length === 0 ||
!isDefined(this.activeTableStyle.regionColumn)
) {
return;
}
return {
id: "regionMapping",
name: "Region Mapping",
options: allRegionProviders.map((regionProvider) => {
return {
name: regionProvider.description,
id: regionProvider.regionType
};
}),
allowUndefined: true,
selectedId: this.activeTableStyle.regionColumn?.regionType?.regionType,
setDimensionValue: (
stratumId: string,
regionType: string | undefined
) => {
let columnTraits = this.columns?.find(
(column) => column.name === this.activeTableStyle.regionColumn?.name
);
if (!isDefined(columnTraits)) {
columnTraits = this.addObject(
stratumId,
"columns",
this.activeTableStyle.regionColumn!.name
)!;
columnTraits.setTrait(
stratumId,
"name",
this.activeTableStyle.regionColumn!.name
);
}
columnTraits.setTrait(stratumId, "regionType", regionType);
}
};
}
/**
* Creates SelectableDimension for region column - the options contains a list of all columns.
* {@link TableTraits#enableManualRegionMapping} must be enabled.
*/
get regionColumnDimensions(): SelectableDimensionEnum | undefined {
if (!isDefined(this.regionProviderLists)) {
return;
}
return {
id: "regionColumn",
name: "Region Column",
options: this.tableColumns.map((col) => {
return {
name: col.name,
id: col.name
};
}),
selectedId: this.activeTableStyle.regionColumn?.name,
setDimensionValue: (
stratumId: string,
regionCol: string | undefined
) => {
this.defaultStyle.setTrait(stratumId, "regionColumn", regionCol);
}
};
}
get regionMappingDimensions(): SelectableDimensionGroup {
return {
id: "Manual Region Mapping",
type: "group",
selectableDimensions: filterOutUndefined([
this.regionColumnDimensions,
this.regionProviderDimensions
])
};
}
/**
* Creates SelectableDimension for region column - the options contains a list of all columns.
* {@link TableColorStyleTraits#zScoreFilter} must be enabled and {@link TableColorMap#zScoreFilterValues} must detect extreme (outlier) values
*/
get outlierFilterDimension(): SelectableDimension | undefined {
if (
!this.activeTableStyle.colorTraits.zScoreFilter ||
!this.activeTableStyle.tableColorMap.zScoreFilterValues
) {
return;
}
return {
id: "outlierFilter",
options: [
{ id: "true", name: i18next.t("models.tableData.zFilterEnabled") },
{ id: "false", name: i18next.t("models.tableData.zFilterDisabled") }
],
selectedId: this.activeTableStyle.colorTraits.zScoreFilterEnabled
? "true"
: "false",
setDimensionValue: (stratumId: string, value) => {
updateModelFromJson(this, stratumId, {
defaultStyle: {
color: { zScoreFilterEnabled: value === "true" }
}
}).logError("Failed to update zScoreFilterEnabled");
},
placement: "belowLegend",
type: "checkbox"
};
}
/**
* Creates SelectableDimension to disable time - this will show if each rowGroup only has a single time
*/
get timeDisableDimension(): SelectableDimension | undefined {
// Return nothing if no active time column and if the active time column has been explicitly hidden (using this.defaultStyle.time.timeColumn = null)
// or if time column doesn't have at least one interval
if (this.mapItems.length === 0 || !this.showDisableTimeOption) return;
return {
id: "disableTime",
options: [
{
id: "true",
name: i18next.t("models.tableData.timeDimensionEnabled")
},
{
id: "false",
name: i18next.t("models.tableData.timeDimensionDisabled")
}
],
selectedId:
this.defaultStyle.time.timeColumn === null ? "false" : "true",
setDimensionValue: (stratumId: string, value) => {
// We have to set showDisableTimeOption to true - or this will hide when time column is disabled
this.setTrait(stratumId, "showDisableTimeOption", true);
this.defaultStyle.time.setTrait(
stratumId,
"timeColumn",
value === "true" ? undefined : null
);
},
type: "checkbox"
};
}
get rowIds(): number[] {
const nRows = (this.dataColumnMajor?.[0]?.length || 1) - 1;
const ids = [...new Array(nRows).keys()];
return ids;
}
get isSampled(): boolean {
return this.activeTableStyle.isSampled;
}
get discreteTimes():
| { time: string; tag: string | undefined }[]
| undefined {
if (!this.activeTableStyle.moreThanOneTimeInterval) return;
const dates = this.activeTableStyle.timeColumn?.valuesAsDates.values;
if (dates === undefined) {
return;
}
// is it correct for discrete times to remove duplicates?
// see discussion on https://github.com/TerriaJS/terriajs/pull/4577
// duplicates will mess up the indexing problem as our `<DateTimePicker />`
// will eliminate duplicates on the UI front, so given the datepicker
// expects uniques, return uniques here
const times = new Set<string>();
for (let i = 0; i < dates.length; i++) {
const d = dates[i];
if (d) {
times.add(d.toISOString());
}
}
return Array.from(times).map((time) => ({ time, tag: undefined }));
}
/** This is a temporary button which shows in the Legend in the Workbench, if custom styling has been applied. */
get legendButton() {
return this.activeTableStyle.isCustom
? {
title: "Custom",
onClick: action(() => {
SelectableDimensionWorkflow.runWorkflow(
this.terria,
new TableStylingWorkflow(this)
);
})
}
: undefined;
}
findFirstColumnByType(type: TableColumnType): TableColumn | undefined {
return this.tableColumns.find((column) => column.type === type);
}
findColumnByName(name: string | undefined): TableColumn | undefined {
return isDefined(name)
? this.tableColumns.find((column) => column.name === name)
: undefined;
}
protected async forceLoadMapItems() {
try {
const dataColumnMajor = await this.forceLoadTableData();
// We need to make sure the region provider is loaded before loading
// region mapped tables.
await this.loadRegionProviderList();
if (dataColumnMajor !== undefined && dataColumnMajor !== null) {
runInAction(() => {
this.dataColumnMajor = dataColumnMajor;
});
}
// Load region IDS if region mapping
const activeRegionType = this.activeTableStyle.regionColumn?.regionType;
if (activeRegionType) {
await activeRegionType.loadRegionIDs();
}
} catch (e) {
// Clear data if error occurs
runInAction(() => {
this.dataColumnMajor = undefined;
});
throw e;
}
}
/**
* Forces load of the table data. This method does _not_ need to consider
* whether the table data is already loaded.
*
* It is guaranteed that `loadMetadata` has finished before this is called, and `regionProviderList` is set.
*
* You **can not** make changes to observables until **after** an asynchronous call {@see AsyncLoader}.
*/
protected abstract forceLoadTableData(): Promise<string[][] | undefined>;
/** Load all region provider lists
* These are loaded from terria.configParameters.regionMappingDefinitionsUrl
*/
async loadRegionProviderList() {
if (isDefined(this.regionProviderLists)) return;
// regionMappingDefinitionsUrl is deprecated - but we use it instead of regionMappingDefinitionsUrls if defined
const urls = isDefined(
this.terria.configParameters.regionMappingDefinitionsUrl
)
? [this.terria.configParameters.regionMappingDefinitionsUrl]
: this.terria.configParameters.regionMappingDefinitionsUrls;
// Load all region in parallel (but preserve order)
const regionProviderLists = await Promise.all(
urls.map(
async (url, i) =>
// Note can be called many times - all promises/results are cached in RegionProviderList.metaList
await RegionProviderList.fromUrl(url, this.terria.corsProxy)
)
);
runInAction(() => (this.regionProviderLists = regionProviderLists));
}
/*
* Appends new table data in column major format to this table.
* It is assumed that the column order is the same for both the tables.
*/
append(dataColumnMajor2: string[][]) {
if (
this.dataColumnMajor !== undefined &&
this.dataColumnMajor.length !== dataColumnMajor2.length
) {
throw new DeveloperError(
"Cannot add tables with different numbers of columns."
);
}
const appended = this.dataColumnMajor || [];
dataColumnMajor2.forEach((newRows, col) => {
if (appended[col] === undefined) {
appended[col] = [];
}
appended[col].push(...newRows);
});
this.dataColumnMajor = appended;
}
private readonly createLongitudeLatitudeDataSource = createTransformer(
(style: TableStyle): DataSource | undefined => {
if (!style.isPoints()) {
return undefined;
}
const dataSource = new CustomDataSource(this.name || "Table");
dataSource.entities.suspendEvents();
let features: TerriaFeature[];
if (style.isTimeVaryingPointsWithId()) {
features = createLongitudeLatitudeFeaturePerId(style);
} else {
features = createLongitudeLatitudeFeaturePerRow(style);
}
// _catalogItem property is needed for some feature picking functions (eg `featureInfoTemplate`)
features.forEach((f) => {
f._catalogItem = this;
dataSource.entities.add(f);
});
dataSource.show = this.show;
dataSource.entities.resumeEvents();
return dataSource;
}
);
private readonly createRegionMappedImageryProvider = createTransformer(
(input: {
style: TableStyle;
currentTime: JulianDate | undefined;
}): ImageryProvider | undefined =>
createRegionMappedImageryProvider(input.style, input.currentTime)
);
private readonly getTableColumn: ITransformer<number, TableColumn> =
createTransformer((index: number) => {
return new TableColumn(this, index);
});
private readonly getTableStyle: ITransformer<number, TableStyle> =
createTransformer((index: number) => {
return new TableStyle(this, index);
});
}
return TableMixin;
}
namespace TableMixin {
export interface Instance
extends InstanceType<ReturnType<typeof TableMixin>> {}
export function isMixedInto(model: any): model is Instance {
return model && model.hasTableMixin;
}
}
export default TableMixin;