UNPKG

terriajs

Version:

Geospatial data visualization platform.

669 lines 26.5 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; 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 ScalePointSizeMap from "../Map/SizeMap/ScalePointSizeMap"; import CommonStrata from "../Models/Definition/CommonStrata"; import createCombinedModel from "../Models/Definition/createCombinedModel"; import TableColorMap from "./TableColorMap"; 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 { tableModel; styleNumber; /** * * @param tableModel TableMixin catalog member * @param styleNumber Index of styleTraits in tableModel (if undefined, then default style will be used) */ constructor(tableModel, styleNumber) { this.tableModel = tableModel; this.styleNumber = styleNumber; 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() { return (this.styleTraits.id ?? (isDefined(this.styleNumber) ? "Style" + this.styleNumber : "Default Style")); } get title() { 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() { 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() { 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() { 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() { 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() { return this.styleTraits.time; } /** Compute rectangle for point (lat/long) based table styles */ get rectangle() { if (this.isPoints()) { const bounds = [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() { return this.resolveColumn(this.styleTraits.longitudeColumn); } /** * Gets the latitude column for this style, if any. */ get latitudeColumn() { return this.resolveColumn(this.styleTraits.latitudeColumn); } /** * Gets the region column for this style, if any. */ get regionColumn() { 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() { 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() { return this.timeTraits.timeColumn === null ? undefined : this.resolveColumn(this.timeTraits.timeColumn); } /** * Gets the end time column for this style, if any. */ get endTimeColumn() { return this.resolveColumn(this.timeTraits.endTimeColumn); } /** * Gets the chart X-axis column for this style, if any. */ get xAxisColumn() { return this.resolveColumn(this.chartTraits.xAxisColumn); } /** * Gets the color column for this style, if any. */ get colorColumn() { return this.resolveColumn(this.colorTraits.colorColumn); } /** * Gets the scale column for this style, if any. */ get pointSizeColumn() { 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() { return (this.longitudeColumn !== undefined && this.latitudeColumn !== undefined); } /** * Determines if this style is visualized as time varying points tracked by id */ isTimeVaryingPointsWithId() { 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() { return this.regionColumn !== undefined; } /** * Determines if this style is visualized on a chart. */ isChart() { 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(this.tableModel, this.styleTraits, "point"); } get outlineStyleMap() { return new TableStyleMap(this.tableModel, this.styleTraits, "outline"); } get trailStyleMap() { return new TableStyleMap(this.tableModel, this.styleTraits, "trail"); } get labelStyleMap() { return new TableStyleMap(this.tableModel, this.styleTraits, "label"); } get 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() { const timeColumn = this.timeColumn; if (timeColumn === undefined) { return; } const lastDate = timeColumn.valuesAsJulianDates.maximum; const intervals = 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); 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. */ get startJulianDates() { 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 = 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. */ get finishJulianDates() { if (this.endTimeColumn) { return this.endTimeColumn.valuesAsJulianDates.values; } const timeColumn = this.timeColumn; if (timeColumn === undefined) { return; } const startDates = timeColumn.valuesAsJulianDates.values; const finishDates = 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() { const colorColumn = this.colorColumn; if (colorColumn?.traits?.format) return colorColumn?.traits?.format; 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. */ calculateFinishDatesFromStartDates(startDates, defaultFinalDurationSeconds) { const sortedStartDates = 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 = 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, d2) => 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) { if (name === undefined) { return undefined; } return this.tableModel.tableColumns.find((column) => column.name === name); } } __decorate([ computed ], TableStyle.prototype, "ready", null); __decorate([ computed ], TableStyle.prototype, "id", null); __decorate([ computed ], TableStyle.prototype, "title", null); __decorate([ computed ], TableStyle.prototype, "hidden", null); __decorate([ computed ], TableStyle.prototype, "styleTraits", null); __decorate([ computed ], TableStyle.prototype, "isCustom", null); __decorate([ computed ], TableStyle.prototype, "colorTraits", null); __decorate([ computed ], TableStyle.prototype, "pointSizeTraits", null); __decorate([ computed ], TableStyle.prototype, "chartTraits", null); __decorate([ computed ], TableStyle.prototype, "timeTraits", null); __decorate([ computed ], TableStyle.prototype, "rectangle", null); __decorate([ computed ], TableStyle.prototype, "longitudeColumn", null); __decorate([ computed ], TableStyle.prototype, "latitudeColumn", null); __decorate([ computed ], TableStyle.prototype, "regionColumn", null); __decorate([ computed ], TableStyle.prototype, "idColumns", null); __decorate([ computed ], TableStyle.prototype, "timeColumn", null); __decorate([ computed ], TableStyle.prototype, "endTimeColumn", null); __decorate([ computed ], TableStyle.prototype, "xAxisColumn", null); __decorate([ computed ], TableStyle.prototype, "colorColumn", null); __decorate([ computed ], TableStyle.prototype, "pointSizeColumn", null); __decorate([ computed ], TableStyle.prototype, "isSampled", null); __decorate([ computed ], TableStyle.prototype, "tableColorMap", null); __decorate([ computed ], TableStyle.prototype, "colorMap", null); __decorate([ computed ], TableStyle.prototype, "pointStyleMap", null); __decorate([ computed ], TableStyle.prototype, "outlineStyleMap", null); __decorate([ computed ], TableStyle.prototype, "trailStyleMap", null); __decorate([ computed ], TableStyle.prototype, "labelStyleMap", null); __decorate([ computed ], TableStyle.prototype, "pointSizeMap", null); __decorate([ computed ], TableStyle.prototype, "timeIntervals", null); __decorate([ computed ], TableStyle.prototype, "moreThanOneTimeInterval", null); __decorate([ computed ], TableStyle.prototype, "startJulianDates", null); __decorate([ computed ], TableStyle.prototype, "finishJulianDates", null); __decorate([ computed ], TableStyle.prototype, "groupByColumns", null); __decorate([ computed ], TableStyle.prototype, "rowGroups", null); __decorate([ computed ], TableStyle.prototype, "numberFormatOptions", null); /** Create row group ID by concatenating values for columns */ export function createRowGroupId(rowId, columns) { 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) { const nonNullDates = dates.filter((d) => !!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, seconds) { return JulianDate.addSeconds(date, seconds, new JulianDate()); } function estimateFinalDurationSeconds(sortedDates) { const n = sortedDates.length; if (n > 1) { const finalDurationSeconds = JulianDate.secondsDifference(sortedDates[n - 1], sortedDates[0]) / (n - 1); return finalDurationSeconds; } } //# sourceMappingURL=TableStyle.js.map