terriajs
Version:
Geospatial data visualization platform.
785 lines (692 loc) • 24.6 kB
text/typescript
import { BBox } from "geojson";
import groupBy from "lodash-es/groupBy";
import { computed, makeObservable } from "mobx";
import binarySearch from "terriajs-cesium/Source/Core/binarySearch";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import TimeInterval from "terriajs-cesium/Source/Core/TimeInterval";
import filterOutUndefined from "../Core/filterOutUndefined";
import isDefined from "../Core/isDefined";
import { isJsonNumber } from "../Core/Json";
import ConstantColorMap from "../Map/ColorMap/ConstantColorMap";
import DiscreteColorMap from "../Map/ColorMap/DiscreteColorMap";
import EnumColorMap from "../Map/ColorMap/EnumColorMap";
import ConstantPointSizeMap from "../Map/SizeMap/ConstantPointSizeMap";
import PointSizeMap from "../Map/SizeMap/PointSizeMap";
import ScalePointSizeMap from "../Map/SizeMap/ScalePointSizeMap";
import TableMixin from "../ModelMixins/TableMixin";
import CommonStrata from "../Models/Definition/CommonStrata";
import createCombinedModel from "../Models/Definition/createCombinedModel";
import Model from "../Models/Definition/Model";
import TableChartStyleTraits from "../Traits/TraitsClasses/Table/ChartStyleTraits";
import TableColorStyleTraits from "../Traits/TraitsClasses/Table/ColorStyleTraits";
import { LabelSymbolTraits } from "../Traits/TraitsClasses/Table/LabelStyleTraits";
import { OutlineSymbolTraits } from "../Traits/TraitsClasses/Table/OutlineStyleTraits";
import TablePointSizeStyleTraits from "../Traits/TraitsClasses/Table/PointSizeStyleTraits";
import { PointSymbolTraits } from "../Traits/TraitsClasses/Table/PointStyleTraits";
import TableStyleTraits from "../Traits/TraitsClasses/Table/StyleTraits";
import TableTimeStyleTraits from "../Traits/TraitsClasses/Table/TimeStyleTraits";
import { TrailSymbolTraits } from "../Traits/TraitsClasses/Table/TrailStyleTraits";
import TableColorMap from "./TableColorMap";
import TableColumn from "./TableColumn";
import TableColumnType from "./TableColumnType";
import TableStyleMap from "./TableStyleMap";
const DEFAULT_FINAL_DURATION_SECONDS = 3600 * 24 - 1; // one day less a second, if there is only one date.
/**
* A style controlling how tabular data is displayed.
*/
export default class TableStyle {
/**
*
* @param tableModel TableMixin catalog member
* @param styleNumber Index of styleTraits in tableModel (if undefined, then default style will be used)
*/
constructor(
readonly tableModel: TableMixin.Instance,
readonly styleNumber?: number | undefined
) {
makeObservable(this);
}
/** Is style ready to be used.
* This will be false if any of dependent columns are not ready
*/
get ready() {
return filterOutUndefined([
this.longitudeColumn,
this.latitudeColumn,
this.regionColumn,
this.timeColumn,
this.endTimeColumn,
this.xAxisColumn,
this.colorColumn,
this.pointSizeColumn,
...(this.idColumns ?? [])
]).every((col) => col.ready);
}
/**
* Gets the ID of the style.
*/
get id(): string {
return (
this.styleTraits.id ??
(isDefined(this.styleNumber)
? "Style" + this.styleNumber
: "Default Style")
);
}
get title(): string {
return (
this.styleTraits.title ??
this.tableModel.tableColumns.find((col) => col.name === this.id)?.title ??
this.id
);
}
/** Hide style from "Display Variable" selector if number of colors (EnumColorMap or DiscreteColorMap) is less than 2. As a ColorMap with a single color isn't super useful. */
get hidden() {
if (isDefined(this.styleTraits.hidden)) return this.styleTraits.hidden;
if (this.colorMap instanceof ConstantColorMap) return true;
if (
(this.colorMap instanceof EnumColorMap ||
this.colorMap instanceof DiscreteColorMap) &&
this.colorMap.colors.length < 2
)
return true;
}
/**
* Gets the {@link TableStyleTraits} for this style. The traits are derived
* from the default styles plus this style layered on top of the default.
*/
get styleTraits(): Model<TableStyleTraits> {
if (
isDefined(this.styleNumber) &&
this.styleNumber < this.tableModel.styles.length
) {
const result = createCombinedModel(
this.tableModel.styles[this.styleNumber],
this.tableModel.defaultStyle
);
return result;
} else {
return this.tableModel.defaultStyle;
}
}
/** Is style "custom" - that is - has the style been created/modified by the user (either directly, or indirectly through a share link).
*/
get isCustom() {
const colorTraits = this.colorTraits.strata.get(CommonStrata.user);
const pointSizeTraits = this.pointSizeTraits.strata.get(CommonStrata.user);
const styleTraits = this.styleTraits.strata.get(CommonStrata.user);
return (
(colorTraits?.binColors ?? [])?.length > 0 ||
(colorTraits?.binMaximums ?? [])?.length > 0 ||
(colorTraits?.enumColors ?? [])?.length > 0 ||
isDefined(colorTraits?.numberOfBins) ||
isDefined(colorTraits?.minimumValue) ||
isDefined(colorTraits?.maximumValue) ||
isDefined(colorTraits?.regionColor) ||
isDefined(colorTraits?.nullColor) ||
isDefined(colorTraits?.outlierColor) ||
pointSizeTraits ||
styleTraits?.point ||
styleTraits?.outline ||
styleTraits?.label ||
styleTraits?.trail
);
}
/**
* Gets the {@link TableColorStyleTraits} from the {@link #styleTraits}.
* Returns a default instance of no color traits are specified explicitly.
*/
get colorTraits(): Model<TableColorStyleTraits> {
return this.styleTraits.color;
}
/**
* Gets the {@link TableScaleStyleTraits} from the {@link #styleTraits}.
* Returns a default instance of no scale traits are specified explicitly.
*/
get pointSizeTraits(): Model<TablePointSizeStyleTraits> {
return this.styleTraits.pointSize;
}
/**
* Gets the {@link TableChartStyleTraits} from the {@link #styleTraits}.
* Returns a default instance of no chart traits are specified explicitly.
*/
get chartTraits(): Model<TableChartStyleTraits> {
return this.styleTraits.chart;
}
/**
* Gets the {@link TableTimeStyleTraits} from the {@link #styleTraits}.
* Returns a default instance if no time traits are specified explicitly.
*/
get timeTraits(): Model<TableTimeStyleTraits> {
return this.styleTraits.time;
}
/** Compute rectangle for point (lat/long) based table styles */
get rectangle() {
if (this.isPoints()) {
const bounds: BBox = [Infinity, Infinity, -Infinity, -Infinity];
if (this.longitudeColumn && this.latitudeColumn) {
for (let i = 0; i < this.longitudeColumn.values.length; i++) {
const long = this.longitudeColumn.valuesAsNumbers.values[i];
const lat = this.latitudeColumn.valuesAsNumbers.values[i];
if (isJsonNumber(long) && isJsonNumber(lat)) {
if (bounds[0] > long) {
bounds[0] = long;
}
if (bounds[1] > lat) {
bounds[1] = lat;
}
if (bounds[2] < long) {
bounds[2] = long;
}
if (bounds[3] < lat) {
bounds[3] = lat;
}
}
}
}
// If bbox has no width or height - add crude buffer of .2 degrees
if (bounds[0] === bounds[2]) {
bounds[0] -= 0.1;
bounds[2] += 0.1;
}
if (bounds[1] === bounds[3]) {
bounds[1] -= 0.1;
bounds[3] += 0.1;
}
if (
bounds[0] !== Infinity &&
bounds[1] !== Infinity &&
bounds[2] !== -Infinity &&
bounds[3] !== -Infinity
)
return {
west: bounds[0],
south: bounds[1],
east: bounds[2],
north: bounds[3]
};
}
}
/**
* Gets the longitude column for this style, if any.
*/
get longitudeColumn(): TableColumn | undefined {
return this.resolveColumn(this.styleTraits.longitudeColumn);
}
/**
* Gets the latitude column for this style, if any.
*/
get latitudeColumn(): TableColumn | undefined {
return this.resolveColumn(this.styleTraits.latitudeColumn);
}
/**
* Gets the region column for this style, if any.
*/
get regionColumn(): TableColumn | undefined {
if (this.styleTraits.regionColumn === null) return;
return this.resolveColumn(this.styleTraits.regionColumn);
}
/**
* Gets the columns that together constitute the id, eg: ["lat", "lon"] for
* fixed features or ["id"].
*/
get idColumns(): TableColumn[] | undefined {
const idColumns = filterOutUndefined(
this.timeTraits.idColumns
? this.timeTraits.idColumns.map((name) => this.resolveColumn(name))
: []
);
return idColumns.length > 0 ? idColumns : undefined;
}
/**
* Gets the time column for this style, if any.
*/
get timeColumn(): TableColumn | undefined {
return this.timeTraits.timeColumn === null
? undefined
: this.resolveColumn(this.timeTraits.timeColumn);
}
/**
* Gets the end time column for this style, if any.
*/
get endTimeColumn(): TableColumn | undefined {
return this.resolveColumn(this.timeTraits.endTimeColumn);
}
/**
* Gets the chart X-axis column for this style, if any.
*/
get xAxisColumn(): TableColumn | undefined {
return this.resolveColumn(this.chartTraits.xAxisColumn);
}
/**
* Gets the color column for this style, if any.
*/
get colorColumn(): TableColumn | undefined {
return this.resolveColumn(this.colorTraits.colorColumn);
}
/**
* Gets the scale column for this style, if any.
*/
get pointSizeColumn(): TableColumn | undefined {
const col = this.resolveColumn(this.pointSizeTraits.pointSizeColumn);
if (col?.type === TableColumnType.scalar) return col;
}
/**
* Determines if this style is visualized as points on a map.
*/
isPoints(): this is {
readonly longitudeColumn: TableColumn;
readonly latitudeColumn: TableColumn;
} {
return (
this.longitudeColumn !== undefined && this.latitudeColumn !== undefined
);
}
/**
* Determines if this style is visualized as time varying points tracked by id
*/
isTimeVaryingPointsWithId(): this is {
readonly longitudeColumn: TableColumn;
readonly latitudeColumn: TableColumn;
readonly idColumns: TableColumn[];
readonly timeColumn: TableColumn;
readonly timeIntervals: (JulianDate | null)[];
} {
return (
this.longitudeColumn !== undefined &&
this.latitudeColumn !== undefined &&
this.idColumns !== undefined &&
this.timeColumn !== undefined &&
this.timeIntervals !== undefined &&
this.moreThanOneTimeInterval
);
}
/**
* Determines if this style is visualized as regions on a map.
*/
isRegions(): this is { readonly regionColumn: TableColumn } {
return this.regionColumn !== undefined;
}
/**
* Determines if this style is visualized on a chart.
*/
isChart(): this is {
readonly xAxisColumn: TableColumn;
} {
return this.xAxisColumn !== undefined && this.chartTraits.lines.length > 0;
}
/** Style isSampled by default. TimeTraits.isSampled will be used if defined. If not, and color column is binary - isSampled will be false. */
get isSampled() {
if (isDefined(this.timeTraits.isSampled)) return this.timeTraits.isSampled;
if (isDefined(this.colorColumn) && this.colorColumn.isScalarBinary)
return false;
return true;
}
get tableColorMap() {
return new TableColorMap(
this.tableModel.name ?? this.tableModel.uniqueId,
this.colorColumn,
this.colorTraits
);
}
get colorMap() {
return this.tableColorMap.colorMap;
}
get pointStyleMap() {
return new TableStyleMap<PointSymbolTraits>(
this.tableModel,
this.styleTraits,
"point"
);
}
get outlineStyleMap() {
return new TableStyleMap<OutlineSymbolTraits>(
this.tableModel,
this.styleTraits,
"outline"
);
}
get trailStyleMap() {
return new TableStyleMap<TrailSymbolTraits>(
this.tableModel,
this.styleTraits,
"trail"
);
}
get labelStyleMap() {
return new TableStyleMap<LabelSymbolTraits>(
this.tableModel,
this.styleTraits,
"label"
);
}
get pointSizeMap(): PointSizeMap {
const pointSizeColumn = this.pointSizeColumn;
const pointSizeTraits = this.pointSizeTraits;
if (pointSizeColumn && pointSizeColumn.type === TableColumnType.scalar) {
const maximum = pointSizeColumn.valuesAsNumbers.maximum;
const minimum = pointSizeColumn.valuesAsNumbers.minimum;
if (isDefined(maximum) && isDefined(minimum) && maximum !== minimum) {
return new ScalePointSizeMap(
minimum,
maximum,
pointSizeTraits.nullSize,
pointSizeTraits.sizeFactor,
pointSizeTraits.sizeOffset
);
}
}
// can't scale point size by values in this column, so use same point size for every value
return new ConstantPointSizeMap(pointSizeTraits.sizeOffset);
}
/**
* Returns a `TimeInterval` for each row in the table.
*/
get timeIntervals(): (TimeInterval | null)[] | undefined {
const timeColumn = this.timeColumn;
if (timeColumn === undefined) {
return;
}
const lastDate = timeColumn.valuesAsJulianDates.maximum;
const intervals: (TimeInterval | null)[] = new Array(
timeColumn.valuesAsJulianDates.values.length
).fill(null);
for (let i = 0; i < timeColumn.valuesAsJulianDates.values.length; i++) {
const date = timeColumn.valuesAsJulianDates.values[i];
const startDate = this.startJulianDates?.[i];
const finishDate = this.finishJulianDates?.[i];
if (!date || !startDate || !finishDate) continue;
intervals[i] = new TimeInterval({
start: startDate,
stop: finishDate,
isStopIncluded: JulianDate.equals(finishDate, lastDate),
data: date
});
}
return intervals;
}
/** Is there more than one unique time interval */
get moreThanOneTimeInterval() {
if (this.timeIntervals) {
// Find first non-null time interval
const firstInterval = this.timeIntervals?.find((t) => t) as
| TimeInterval
| undefined;
if (firstInterval) {
// Does there exist an interval which is different from firstInterval (that is to say, does there exist at least two unique intervals)
return !!this.timeIntervals?.find(
(t) =>
t &&
(!firstInterval.start.equals(t.start) ||
!firstInterval.stop.equals(t.stop))
);
}
}
return false;
}
/**
* Returns a start date for each row in the table.
* If `timeTraits.spreadStartTime` is true - the start dates will be the earliest value for all features (eg sensor IDs) - even if the time value is **after** the earliest time step. This means that at time step 0, all features will be displayed.
*/
private get startJulianDates(): (JulianDate | null)[] | undefined {
const timeColumn = this.timeColumn;
if (timeColumn === undefined) {
return;
}
const firstDate = timeColumn.valuesAsJulianDates.minimum;
if (!firstDate) return [];
// Create a new array which will be filled by rowGroups (this will exclude dates which don't have rowGroup (eg invalid regions))
const filteredStartDates: (JulianDate | null)[] = new Array(
timeColumn.valuesAsJulianDates.values.length
).fill(null);
for (let i = 0; i < this.rowGroups.length; i++) {
const rowIds = this.rowGroups[i][1];
// Copy over valid rows
for (let j = 0; j < rowIds.length; j++) {
filteredStartDates[rowIds[j]] =
timeColumn.valuesAsJulianDates.values[rowIds[j]];
}
if (this.timeTraits.spreadStartTime) {
// Find row ID with earliest date in this rowGroup
const firstRowId = rowIds
.filter((id) => filteredStartDates[id])
.sort((idA, idB) =>
JulianDate.compare(
filteredStartDates[idA]!,
filteredStartDates[idB]!
)
)[0];
// Set it to earliest date in the entire column
if (isDefined(firstRowId)) filteredStartDates[firstRowId] = firstDate;
}
}
return filteredStartDates;
}
/**
* Returns a finish date for each row in the table.
*/
private get finishJulianDates(): (JulianDate | null)[] | undefined {
if (this.endTimeColumn) {
return this.endTimeColumn.valuesAsJulianDates.values;
}
const timeColumn = this.timeColumn;
if (timeColumn === undefined) {
return;
}
const startDates = timeColumn.valuesAsJulianDates.values;
const finishDates: (JulianDate | null)[] = new Array(
startDates.length
).fill(null);
// If displayDuration trait is set, use that to set finish date
if (this.timeTraits.displayDuration !== undefined) {
for (let i = 0; i < startDates.length; i++) {
const date = startDates[i];
if (date) {
finishDates[i] = JulianDate.addMinutes(
date,
this.timeTraits.displayDuration!,
new JulianDate()
);
}
}
return finishDates;
}
// Otherwise estimate a final duration value to calculate the end date for groups
// that have only one row. Fallback to a global default if an estimate
// cannot be found.
for (let i = 0; i < this.rowGroups.length; i++) {
const rowIds = this.rowGroups[i][1];
const sortedStartDates = sortedUniqueDates(
rowIds.map((id) => timeColumn.valuesAsJulianDates.values[id])
);
const finalDuration =
estimateFinalDurationSeconds(sortedStartDates) ??
DEFAULT_FINAL_DURATION_SECONDS;
const startDatesForGroup = rowIds.map((id) => startDates[id]);
const finishDatesForGroup = this.calculateFinishDatesFromStartDates(
startDatesForGroup,
finalDuration
);
for (let j = 0; j < finishDatesForGroup.length; j++) {
finishDates[rowIds[j]] = finishDatesForGroup[j];
}
}
return finishDates;
}
/** Columns used in rowGroups - idColumns, latitude/longitude columns or region column
*/
get groupByColumns() {
let groupByCols = this.idColumns;
if (!groupByCols) {
// If points use lat long
if (this.latitudeColumn && this.longitudeColumn) {
groupByCols = [this.latitudeColumn, this.longitudeColumn];
// If region - use region col
} else if (this.regionColumn) groupByCols = [this.regionColumn];
}
return groupByCols ?? [];
}
/** Get rows grouped by id.
*/
get rowGroups() {
const tableRowIds = this.tableModel.rowIds;
return (
Object.entries(
groupBy(tableRowIds, (rowId) =>
createRowGroupId(rowId, this.groupByColumns)
)
)
// Filter out bad IDs
.filter((value) => value[0] !== "")
);
}
get numberFormatOptions(): Intl.NumberFormatOptions | undefined {
const colorColumn = this.colorColumn;
if (colorColumn?.traits?.format)
return colorColumn?.traits?.format as Intl.NumberFormatOptions;
const min =
this.tableColorMap.minimumValue ?? colorColumn?.valuesAsNumbers.minimum;
const max =
this.tableColorMap.maximumValue ?? colorColumn?.valuesAsNumbers.maximum;
if (
colorColumn &&
colorColumn.type === TableColumnType.scalar &&
isDefined(min) &&
isDefined(max)
) {
if (max - min === 0) return;
// We want to show fraction digits depending on how small difference is between min and max.
// This also takes into consideration the default number of legend items - 7
// So we add an extra digit
// For example:
// - if difference is 10 - we want to show one fraction digit
// - if difference is 1 - we want to show two fraction digits
// - if difference is 0.1 - we want to show three fraction digits
// log_10(20/x) achieves this (where x is difference between min and max)
// https://www.wolframalpha.com/input/?i=log_10%2820%2Fx%29
// We use 20 here instead of 10 to give us a more conservative value (that is, we may show an extra fraction digit even if it is not needed)
// So when x >= 20 - we will not show any fraction digits
// Clamp values between 0 and 5
const fractionDigits = Math.max(
0,
Math.min(5, Math.ceil(Math.log10(20 / Math.abs(max - min))))
);
return {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits
};
}
}
/**
* Computes an and end date for each given start date. The end date for a
* given start date is the next higher date in the input. To compute the end
* date for the last date in the input, we estimate a duration based on the
* average interval between dates in the input. If the input has only one
* date, then an estimate cannot be made, in that case we use the
* `defaultFinalDurationSeconds` to compute the end date.
*/
private calculateFinishDatesFromStartDates(
startDates: (JulianDate | null | undefined)[],
defaultFinalDurationSeconds: number
) {
const sortedStartDates: JulianDate[] = sortedUniqueDates(startDates);
// Calculate last date based on if spreadFinishTime is true:
// - If true, use the maximum date in the entire timeColumn
// - If false, use the last date in startDates - which is the last date in the current row group
const lastDate =
this.timeTraits.spreadFinishTime &&
this.timeColumn?.valuesAsJulianDates.maximum
? this.timeColumn.valuesAsJulianDates.maximum
: sortedStartDates[sortedStartDates.length - 1];
const finishDates: (JulianDate | null)[] = new Array(
startDates.length
).fill(null);
for (let i = 0; i < startDates.length; i++) {
const date = startDates[i];
if (!date) {
continue;
}
const nextDateIndex = binarySearch(
sortedStartDates,
date,
(d1: JulianDate, d2: JulianDate) => JulianDate.compare(d1, d2)
);
const nextDate = sortedStartDates[nextDateIndex + 1];
if (nextDate) {
finishDates[i] = nextDate;
} else {
// This is the last date in the row, so calculate a final date
const finalDurationSeconds =
estimateFinalDurationSeconds(sortedStartDates) ||
defaultFinalDurationSeconds;
finishDates[i] = addSecondsToDate(lastDate, finalDurationSeconds);
}
}
return finishDates;
}
resolveColumn(name: string | undefined): TableColumn | undefined {
if (name === undefined) {
return undefined;
}
return this.tableModel.tableColumns.find((column) => column.name === name);
}
}
/** Create row group ID by concatenating values for columns */
export function createRowGroupId(rowId: number, columns: TableColumn[]) {
return columns
.map((col) => {
// If using region column as ID - only use valid regions
if (col.type === TableColumnType.region) {
return col.valuesAsRegions.regionIds[rowId];
}
return col.values[rowId];
})
.join("-");
}
/**
* Returns an array of sorted unique dates
*/
function sortedUniqueDates(
dates: Readonly<(JulianDate | null | undefined)[]>
): JulianDate[] {
const nonNullDates: JulianDate[] = dates.filter((d): d is JulianDate => !!d);
return nonNullDates
.sort((a, b) => JulianDate.compare(a, b))
.filter((d, i, ds) => i === 0 || !JulianDate.equals(d, ds[i - 1]));
}
function addSecondsToDate(date: JulianDate, seconds: number) {
return JulianDate.addSeconds(date, seconds, new JulianDate());
}
function estimateFinalDurationSeconds(
sortedDates: JulianDate[]
): number | undefined {
const n = sortedDates.length;
if (n > 1) {
const finalDurationSeconds =
JulianDate.secondsDifference(sortedDates[n - 1], sortedDates[0]) /
(n - 1);
return finalDurationSeconds;
}
}