UNPKG

terriajs

Version:

Geospatial data visualization platform.

785 lines (692 loc) 24.6 kB
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 */ @computed 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. */ @computed get id(): string { return ( this.styleTraits.id ?? (isDefined(this.styleNumber) ? "Style" + this.styleNumber : "Default Style") ); } @computed 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. */ @computed 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. */ @computed 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). */ @computed 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. */ @computed 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. */ @computed 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. */ @computed 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. */ @computed get timeTraits(): Model<TableTimeStyleTraits> { return this.styleTraits.time; } /** Compute rectangle for point (lat/long) based table styles */ @computed 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. */ @computed get longitudeColumn(): TableColumn | undefined { return this.resolveColumn(this.styleTraits.longitudeColumn); } /** * Gets the latitude column for this style, if any. */ @computed get latitudeColumn(): TableColumn | undefined { return this.resolveColumn(this.styleTraits.latitudeColumn); } /** * Gets the region column for this style, if any. */ @computed 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"]. */ @computed 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. */ @computed 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. */ @computed get endTimeColumn(): TableColumn | undefined { return this.resolveColumn(this.timeTraits.endTimeColumn); } /** * Gets the chart X-axis column for this style, if any. */ @computed get xAxisColumn(): TableColumn | undefined { return this.resolveColumn(this.chartTraits.xAxisColumn); } /** * Gets the color column for this style, if any. */ @computed get colorColumn(): TableColumn | undefined { return this.resolveColumn(this.colorTraits.colorColumn); } /** * Gets the scale column for this style, if any. */ @computed 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. */ @computed get isSampled() { if (isDefined(this.timeTraits.isSampled)) return this.timeTraits.isSampled; if (isDefined(this.colorColumn) && this.colorColumn.isScalarBinary) return false; return true; } @computed get tableColorMap() { return new TableColorMap( this.tableModel.name ?? this.tableModel.uniqueId, this.colorColumn, this.colorTraits ); } @computed get colorMap() { return this.tableColorMap.colorMap; } @computed get pointStyleMap() { return new TableStyleMap<PointSymbolTraits>( this.tableModel, this.styleTraits, "point" ); } @computed get outlineStyleMap() { return new TableStyleMap<OutlineSymbolTraits>( this.tableModel, this.styleTraits, "outline" ); } @computed get trailStyleMap() { return new TableStyleMap<TrailSymbolTraits>( this.tableModel, this.styleTraits, "trail" ); } @computed get labelStyleMap() { return new TableStyleMap<LabelSymbolTraits>( this.tableModel, this.styleTraits, "label" ); } @computed 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. */ @computed 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 */ @computed 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. */ @computed 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. */ @computed 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 */ @computed 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. */ @computed 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] !== "") ); } @computed 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; } }