terriajs
Version:
Geospatial data visualization platform.
1,476 lines (1,305 loc) • 55.7 kB
text/typescript
import bbox from "@turf/bbox";
import {
LineString,
MultiLineString,
MultiPolygon,
Point,
Polygon
} from "geojson";
import i18next from "i18next";
import {
action,
computed,
IReactionDisposer,
makeObservable,
observable,
onBecomeObserved,
onBecomeUnobserved,
override,
reaction,
runInAction,
toJS
} from "mobx";
import { createTransformer } from "mobx-utils";
import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import clone from "terriajs-cesium/Source/Core/clone";
import Color from "terriajs-cesium/Source/Core/Color";
import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError";
import Iso8601 from "terriajs-cesium/Source/Core/Iso8601";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import PolygonHierarchy from "terriajs-cesium/Source/Core/PolygonHierarchy";
import TimeInterval from "terriajs-cesium/Source/Core/TimeInterval";
import TimeIntervalCollection from "terriajs-cesium/Source/Core/TimeIntervalCollection";
import BillboardGraphics from "terriajs-cesium/Source/DataSources/BillboardGraphics";
import ColorMaterialProperty from "terriajs-cesium/Source/DataSources/ColorMaterialProperty";
import ConstantProperty from "terriajs-cesium/Source/DataSources/ConstantProperty";
import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource";
import CzmlDataSource from "terriajs-cesium/Source/DataSources/CzmlDataSource";
import DataSource from "terriajs-cesium/Source/DataSources/DataSource";
import Entity from "terriajs-cesium/Source/DataSources/Entity";
import EntityCollection from "terriajs-cesium/Source/DataSources/EntityCollection";
import GeoJsonDataSource from "terriajs-cesium/Source/DataSources/GeoJsonDataSource";
import PointGraphics from "terriajs-cesium/Source/DataSources/PointGraphics";
import PolygonGraphics from "terriajs-cesium/Source/DataSources/PolygonGraphics";
import PolylineGraphics from "terriajs-cesium/Source/DataSources/PolylineGraphics";
import Property from "terriajs-cesium/Source/DataSources/Property";
import HeightReference from "terriajs-cesium/Source/Scene/HeightReference";
import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo";
import AbstractConstructor from "../Core/AbstractConstructor";
import filterOutUndefined from "../Core/filterOutUndefined";
import formatPropertyValue from "../Core/formatPropertyValue";
import {
explodeMultiPoint,
FeatureCollectionWithCrs,
isPoint
} from "../Core/GeoJson";
import hashFromString from "../Core/hashFromString";
import isDefined from "../Core/isDefined";
import {
isJsonArray,
isJsonNumber,
isJsonObject,
JsonObject
} from "../Core/Json";
import { isJson } from "../Core/loadBlob";
import StandardCssColors from "../Core/StandardCssColors";
import TerriaError, { networkRequestError } from "../Core/TerriaError";
import ProtomapsImageryProvider, {
ProtomapsData
} from "../Map/ImageryProvider/ProtomapsImageryProvider";
import { ProtomapsGeojsonSource } from "../Map/Vector/Protomaps/ProtomapsGeojsonSource";
import { tableStyleToProtomaps } from "../Map/Vector/Protomaps/tableStyleToProtomaps";
import Reproject from "../Map/Vector/Reproject";
import CatalogMemberMixin from "../ModelMixins/CatalogMemberMixin";
import UrlMixin from "../ModelMixins/UrlMixin";
import proxyCatalogItemUrl from "../Models/Catalog/proxyCatalogItemUrl";
import createStratumInstance from "../Models/Definition/createStratumInstance";
import LoadableStratum from "../Models/Definition/LoadableStratum";
import Model, { BaseModel } from "../Models/Definition/Model";
import StratumOrder from "../Models/Definition/StratumOrder";
import TerriaFeature from "../Models/Feature/Feature";
import { TerriaFeatureData } from "../Models/Feature/FeatureData";
import { ViewingControl } from "../Models/ViewingControls";
import TableStylingWorkflow from "../Models/Workflows/TableStylingWorkflow";
import createLongitudeLatitudeFeaturePerRow from "../Table/createLongitudeLatitudeFeaturePerRow";
import TableAutomaticStylesStratum from "../Table/TableAutomaticStylesStratum";
import TableStyle, { createRowGroupId } from "../Table/TableStyle";
import { GeoJsonTraits } from "../Traits/TraitsClasses/GeoJsonTraits";
import { RectangleTraits } from "../Traits/TraitsClasses/MappableTraits";
import StyleTraits from "../Traits/TraitsClasses/StyleTraits";
import { DiscreteTimeAsJS } from "./DiscretelyTimeVaryingMixin";
import { ExportData } from "./ExportableMixin";
import FeatureInfoUrlTemplateMixin from "./FeatureInfoUrlTemplateMixin";
import { ImageryParts, isDataSource } from "./MappableMixin";
import TableMixin from "./TableMixin";
import PinBuilder from "terriajs-cesium/Source/Core/PinBuilder";
import VerticalOrigin from "terriajs-cesium/Source/Scene/VerticalOrigin";
export const FEATURE_ID_PROP = "_id_";
const SIMPLE_STYLE_KEYS = [
"marker-size",
"marker-color",
"marker-symbol",
"marker-opacity",
"marker-url",
"stroke",
"stroke-opacity",
"stroke-width",
"marker-stroke-width",
"polyline-stroke-width",
"polygon-stroke-width",
"fill",
"fill-opacity"
];
class GeoJsonStratum extends LoadableStratum(GeoJsonTraits) {
static stratumName = "geojson";
constructor(private readonly _item: GeoJsonMixin.Instance) {
super();
makeObservable(this);
}
duplicateLoadableStratum(newModel: BaseModel): this {
return new GeoJsonStratum(newModel as GeoJsonMixin.Instance) as this;
}
static load(item: GeoJsonMixin.Instance) {
return new GeoJsonStratum(item);
}
@computed
get rectangle() {
if (this._item._readyData) {
try {
const geojsonBbox = bbox(this._item._readyData);
return createStratumInstance(RectangleTraits, {
west: geojsonBbox[0],
south: geojsonBbox[1],
east: geojsonBbox[2],
north: geojsonBbox[3]
});
} catch (e) {
TerriaError.from(e, "Failed to create `rectangle` for GeoJSON").log();
}
}
}
get opacity() {
return 1;
}
@computed
get disableSplitter() {
// Disable splitter if mapItems has any datasources
return this._item.mapItems.find(isDataSource) ? true : undefined;
}
@computed
get disableOpacityControl() {
// Disable opacity if mapItems has any datasources
return this._item.mapItems.find(isDataSource) ? true : undefined;
}
get showDisableStyleOption() {
return true;
}
@computed get forceCesiumPrimitives() {
// Disable TableStyling for the following:
// If MultiPoint features exist
// If more than 50% of features have simple style properties - disable table styling
if (
this._item.featureCounts.multiPoint > 0 ||
this._item.featureCounts.simpleStyle / this._item.featureCounts.total >=
0.5
) {
return true;
}
}
}
StratumOrder.addLoadStratum(GeoJsonStratum.stratumName);
interface FeatureCounts {
point: number;
multiPoint: number;
/** Line includes MultiLine features */
line: number;
/** Polygon includes MultiPolygon features */
polygon: number;
/** Count of features with simplestyle-spec properties (eg "fill-color") */
simpleStyle: number;
total: number;
}
type BaseType = Model<GeoJsonTraits>;
function GeoJsonMixin<T extends AbstractConstructor<BaseType>>(Base: T) {
abstract class GeoJsonMixin extends TableMixin(
FeatureInfoUrlTemplateMixin(UrlMixin(Base))
) {
@observable
private _dataSource:
| CustomDataSource
| CzmlDataSource
| GeoJsonDataSource
| undefined;
@observable
private _imageryProvider: ProtomapsImageryProvider | undefined;
private tableStyleReactionDisposer: IReactionDisposer | undefined;
/** Geojson FeatureCollection in WGS84 */
@observable.ref _readyData?: FeatureCollectionWithCrs;
/** Number of features in _readyData FeatureCollection */
@observable featureCounts: FeatureCounts = {
point: 0,
multiPoint: 0,
line: 0,
polygon: 0,
simpleStyle: 0,
total: 0
};
constructor(...args: any[]) {
super(...args);
makeObservable(this);
// Add GeoJsonStratum
if (this.strata.get(GeoJsonStratum.stratumName) === undefined) {
runInAction(() => {
this.strata.set(
GeoJsonStratum.stratumName,
GeoJsonStratum.load(this)
);
});
}
// Add TableAutomaticStylesStratum
if (
this.strata.get(TableAutomaticStylesStratum.stratumName) === undefined
) {
this.strata.set(
TableAutomaticStylesStratum.stratumName,
new TableAutomaticStylesStratum(this)
);
}
// Setup table style reactions
// We should only update geojson table styling when our map items have consumers
onBecomeObserved(
this,
"mapItems",
this.startTableStyleReaction.bind(this)
);
onBecomeUnobserved(
this,
"mapItems",
this.stopTableStyleReaction.bind(this)
);
}
private startTableStyleReaction() {
if (!this.tableStyleReactionDisposer) {
// Update protomaps imagery provider if activeTableStyle changes
this.tableStyleReactionDisposer = reaction(
() => getStyleReactiveDependencies(this),
() => {
if (
this._imageryProvider &&
this.readyData &&
this.useTableStylingAndProtomaps
) {
runInAction(() => {
this._imageryProvider = this.createProtomapsImageryProvider(
this.readyData!
);
});
}
},
// Fire immediately, just in case reactions change while not observing mapItems
{ fireImmediately: true }
);
}
}
private stopTableStyleReaction() {
if (this.tableStyleReactionDisposer) {
this.tableStyleReactionDisposer();
this.tableStyleReactionDisposer = undefined;
}
}
get isGeoJson() {
return true;
}
@override
get name() {
if (CatalogMemberMixin.isMixedInto(this.sourceReference)) {
return super.name || this.sourceReference.name;
}
return super.name;
}
@override
get cacheDuration(): string {
if (isDefined(super.cacheDuration)) {
return super.cacheDuration;
}
return "1d";
}
/**
* Returns the final raw data after all transformations are applied.
* (Geojson FeatureCollection in WGS84)
*/
@computed get readyData() {
return this._readyData;
}
@override
get _canExportData() {
return isDefined(this.readyData);
}
protected async _exportData(): Promise<ExportData | undefined> {
if (isDefined(this.readyData)) {
let name = this.name || this.uniqueId || "data.geojson";
if (!isJson(name)) {
name = `${name}.geojson`;
}
return {
name,
file: new Blob([JSON.stringify(this.readyData)])
};
}
throw new TerriaError({
sender: this,
message: "No data available to download."
});
}
@override
get mapItems() {
if (this.isLoadingMapItems) {
return [];
}
if (this._dataSource) {
this._dataSource.show = this.show;
}
let points = undefined;
if (this.useTableStylingAndProtomaps) {
const pts = this.createPoints(this.activeTableStyle);
if (pts && pts.entities.values.length !== 0) {
points = pts;
points.show = this.show;
}
}
return filterOutUndefined([
points,
this._dataSource,
this._imageryProvider
? ({
imageryProvider: this._imageryProvider,
show: this.show,
alpha: this.opacity,
clippingRectangle: undefined
} as ImageryParts)
: undefined
]);
}
/**
* {@link FeatureInfoUrlTemplateMixin.buildFeatureFromPickResult}
*/
buildFeatureFromPickResult(
_screenPosition: Cartesian2 | undefined,
pickResult: any
): TerriaFeature | undefined {
if (pickResult instanceof Entity) {
return TerriaFeature.fromEntityCollectionOrEntity(pickResult);
} else if (isDefined(pickResult?.id)) {
return TerriaFeature.fromEntityCollectionOrEntity(pickResult.id);
}
}
/** Only use MapboxVectorTiles (through geojson-vt and protomaps.js) if enabled and not using unsupported traits
* For more info see GeoJsonMixin.forceLoadMapItems
*/
@computed
get useTableStylingAndProtomaps() {
return (
!this.forceCesiumPrimitives &&
!isDefined(this.czmlTemplate) &&
// Table styling doesn't support the old GeoJson StyleTraits
Object.keys(this.style.traits).every(
(styleTrait) =>
!isDefined(this.style[styleTrait as keyof StyleTraits])
) &&
!isDefined(this.timeProperty) &&
!isDefined(this.heightProperty) &&
(!isDefined(this.perPropertyStyles) ||
this.perPropertyStyles.length === 0)
);
}
/** Remove chart items from TableMixin.chartItems */
@override
get chartItems() {
return [];
}
/**
* Forces load of the geojson data. This method does _not_ need to consider
* whether the geojson is already loaded.
*
* It is guaranteed that `loadMetadata` has finished before this is called.
*
* You **can not** make changes to observables until **after** an asynchronous call {@see AsyncLoader}.
*
* Errors can be thrown here.
*/
protected abstract forceLoadGeojsonData(): Promise<
FeatureCollectionWithCrs | undefined
>;
/** GeojsonMixin has 3 rendering modes:
* - CZML:
* - if `czmlTemplate` is defined (see `GeoJsonTraits.czmlTemplate`)
* - Table styling / Mapbox vector tiles (through geojson-vt and protomaps.js)
* - Will be used by default, if not using unsupported traits (see below)
* - Cesium primitives if:
* - `GeoJsonTraits.forceCesiumPrimitives = true`
* - Using `timeProperty` or `heightProperty` or `perPropertyStyles` or simple-style `marker-symbol`
* - More than 50% of GeoJSON features have simply-style properties (eg "fill-color")
* - MultiPoint features are in GeoJSON (not supported by Table styling)
*/
protected async forceLoadMapItems(): Promise<void> {
const czmlTemplate = this.czmlTemplate;
const filterByProperties = this.filterByProperties;
const explodeMultiPoints = this.explodeMultiPoints;
let geoJson: FeatureCollectionWithCrs | undefined;
try {
geoJson = await this.forceLoadGeojsonData();
if (geoJson === undefined) {
return;
}
const geoJsonWgs84 = await reprojectToGeographic(
geoJson,
this.terria.configParameters.proj4ServiceBaseUrl
);
const featureCounts: FeatureCounts = {
point: 0,
multiPoint: 0,
line: 0,
polygon: 0,
simpleStyle: 0,
total: 0
};
// We will re-add features depending if filterByProperties - or geometry is invalid
const features = geoJsonWgs84.features;
geoJsonWgs84.features = [];
let currentFeatureId = 0;
for (let i = 0; i < features.length; i++) {
const feature = features[i];
// Ignore features without geometry or type
if (!isJsonObject(feature.geometry, false) || !feature.geometry.type)
continue;
// Ignore features with invalid coordinates
if (
!isJsonArray(feature.geometry.coordinates, false) ||
feature.geometry.coordinates.length === 0
)
continue;
if (!feature.properties) {
feature.properties = {};
}
// Filter features by `featureFilterByProps` trait if defined
if (
filterByProperties &&
!Object.entries(filterByProperties).every(
([key, value]) => feature.properties![key] === value
)
) {
continue;
}
if (explodeMultiPoints && feature.geometry.type === "MultiPoint") {
// Replace the MultiPoint with equivalent Point features and repeat
// the iteration to pick up the exploded features.
features.splice(i, 1, ...explodeMultiPoint(feature));
i--;
continue;
}
geoJsonWgs84.features.push(feature);
// Add feature index to FEATURE_ID_PROP ("_id_") feature property
// This is used to refer to each feature in TableMixin (as row ID)
const properties = feature.properties!;
properties[FEATURE_ID_PROP] = currentFeatureId;
// Count features types
if (feature.geometry.type === "Point") {
featureCounts.point++;
} else if (feature.geometry.type === "MultiPoint") {
featureCounts.multiPoint++;
} else if (
feature.geometry.type === "LineString" ||
feature.geometry.type === "MultiLineString"
) {
featureCounts.line++;
} else if (
feature.geometry.type === "Polygon" ||
feature.geometry.type === "MultiPolygon"
) {
featureCounts.polygon++;
}
// Does feature include simplestyle-spec properties (eg "fill-colour)")
if (SIMPLE_STYLE_KEYS.find((key) => properties[key])) {
featureCounts.simpleStyle++;
}
featureCounts.total++;
// Note it is important to increment currentFeatureId only if we are including the feature - as this needs to match the row ID in TableMixin (through dataColumnMajor)
currentFeatureId++;
}
runInAction(() => {
this.featureCounts = featureCounts;
if (featureCounts.total === 0) {
this._readyData = undefined;
} else {
this._readyData = geoJsonWgs84;
}
});
if (isDefined(czmlTemplate)) {
const dataSource = await this.loadCzmlDataSource(geoJsonWgs84);
runInAction(() => {
this._dataSource = dataSource;
this._imageryProvider = undefined;
});
} else if (runInAction(() => this.useTableStylingAndProtomaps)) {
runInAction(() => {
this._imageryProvider =
this.createProtomapsImageryProvider(geoJsonWgs84);
});
} else {
const dataSource = await this.loadGeoJsonDataSource(geoJsonWgs84);
if (this.clustering.enabled) {
const pinBackgroundColor = this.clustering.pinBackgroundColor;
const pinSize = this.clustering.pinSize;
const pinBuilder = new PinBuilder();
dataSource.clustering.enabled = true;
dataSource.clustering.pixelRange = this.clustering.pixelRange;
dataSource.clustering.minimumClusterSize =
this.clustering.minimumClusterSize;
dataSource.clustering.clusterEvent.addEventListener(
function (entities, cluster) {
cluster.label.show = false;
cluster.billboard.verticalOrigin = VerticalOrigin.BOTTOM;
cluster.billboard.image = pinBuilder
.fromText(
entities.length.toLocaleString(),
Color.fromCssColorString(pinBackgroundColor),
pinSize
)
.toDataURL();
cluster.billboard.show = true;
}
);
}
runInAction(() => {
this._dataSource = dataSource;
this._imageryProvider = undefined;
});
}
this._dataSource?.entities.values.forEach(
(entity) => ((entity as any)._catalogItem = this)
);
} catch (e) {
throw networkRequestError(
TerriaError.from(e, {
title: i18next.t("models.geoJson.errorLoadingTitle"),
message: i18next.t("models.geoJson.errorParsingMessage")
})
);
}
}
@action
private addPerPropertyStyleToGeoJson(fc: FeatureCollectionWithCrs) {
for (let i = 0; i < fc.features.length; i++) {
const featureProperties = fc.features[i].properties;
if (featureProperties === null) {
return;
}
const featurePropertiesEntires = Object.entries(featureProperties);
const matchedStyles = this.perPropertyStyles.filter((style) => {
const stylePropertiesEntries = Object.entries(style.properties ?? {});
// For every key-value pair in the style, is there an identical one in the feature's properties?
return stylePropertiesEntries.every(
([styleKey, styleValue]) =>
featurePropertiesEntires.find(([featKey, featValue]) => {
if (typeof styleValue === "string" && !style.caseSensitive) {
return (
featKey === styleKey &&
(typeof featValue === "string"
? featValue
: featValue.toString()
).toLowerCase() === styleValue.toLowerCase()
);
}
return featKey === styleKey && featValue === styleValue;
}) !== undefined
);
});
if (matchedStyles !== undefined) {
for (const matched of matchedStyles) {
for (const trait of Object.keys(matched.style.traits)) {
featureProperties[trait] =
// @ts-expect-error - TS can't tell that `trait` is of the correct index type for style
matched.style[trait] ?? featureProperties[trait];
}
}
}
}
}
// Create point features using TableMixin.createLongitudeLatitudeFeaturePerRow
// Used with table styling
// Line and Polygon features are handled by Protomaps
private readonly createPoints = createTransformer(
(style: TableStyle): DataSource | undefined => {
if (!this.readyData) return;
const latitudes: (number | null)[] = [];
const longitudes: (number | null)[] = [];
for (let i = 0; i < this.readyData.features.length; i++) {
const feature = this.readyData.features[i];
if (!isPoint(feature)) {
latitudes.push(null);
longitudes.push(null);
continue;
}
latitudes.push(feature.geometry.coordinates[1]);
longitudes.push(feature.geometry.coordinates[0]);
}
const dataSource = new CustomDataSource(this.name || "Table");
dataSource.entities.suspendEvents();
const features: Entity[] = createLongitudeLatitudeFeaturePerRow(
style,
longitudes,
latitudes
);
// _catalogItem property is needed for some feature picking functions (eg FeatureInfoUrlTemplateMixin)
features.forEach((f) => {
(f as any)._catalogItem = this;
dataSource.entities.add(f);
});
dataSource.entities.resumeEvents();
return dataSource;
}
);
@action
private createProtomapsImageryProvider(geoJson: FeatureCollectionWithCrs) {
// Don't need protomaps unless we have lines and polygons to show
// Points are handled by this.createPoints()
if (this.featureCounts.line + this.featureCounts.polygon === 0) return;
const { paintRules, labelRules, currentTimeRows } =
tableStyleToProtomaps(this);
let protomapsData: ProtomapsData = Object.assign({}, geoJson, {
features: geoJson.features.filter((f) => f.geometry.type !== "Point")
});
// Are we creating a protomaps imagery provider with the same geojson data (readyData)?
// If so we can copy GeojsonSource over to save running geojson-vt again
if (
this._imageryProvider instanceof ProtomapsImageryProvider &&
this._imageryProvider.source instanceof ProtomapsGeojsonSource &&
this._imageryProvider.source.geojsonObject === this.readyData
) {
protomapsData = this._imageryProvider.source;
}
let provider = new ProtomapsImageryProvider({
terria: this.terria,
data: protomapsData,
id: this.uniqueId,
paintRules,
labelRules,
// Process picked features to add terriaFeatureData (with rowIds)
// This is used by tableFeatureInfoContext to add time-series chart
processPickedFeatures: async (features) => {
if (!currentTimeRows) return features;
const processedFeatures: ImageryLayerFeatureInfo[] = [];
features.forEach((f) => {
const rowId = f.properties?.[FEATURE_ID_PROP];
if (isDefined(rowId) && currentTimeRows?.includes(rowId)) {
// To find rowIds for all features in a row group:
// re-create the rowGroupId and then look up in the activeTableStyle.rowGroups
const rowGroupId = createRowGroupId(
rowId,
this.activeTableStyle.groupByColumns
);
const terriaFeatureData: TerriaFeatureData = {
...f.data,
type: "terriaFeatureData",
rowIds: this.activeTableStyle.rowGroups.find(
(group) => group[0] === rowGroupId
)?.[1]
};
f.data = terriaFeatureData;
processedFeatures.push(f);
}
});
return processedFeatures;
}
});
provider = this.wrapImageryPickFeatures(provider);
return provider;
}
private async loadCzmlDataSource(
geoJson: FeatureCollectionWithCrs
): Promise<CzmlDataSource> {
const czmlTemplate = runInAction(() => toJS(this.czmlTemplate));
const rootCzml = [
{
id: "document",
name: "CZML",
version: "1.0"
}
];
// Create a czml packet for each geoJson Point/Polygon feature
// For point: set czml position (CartographicDegrees) to point coordinates
// For polygon: set czml positions array (CartographicDegreesListValue) for the `polygon` property
// Set czml properties to feature properties
for (let i = 0; i < geoJson.features.length; i++) {
const feature = geoJson.features[i];
if (feature === null) {
continue;
}
if (feature.geometry?.type === "Point") {
const czml = clone(czmlTemplate ?? {}, true);
const point = feature.geometry as Point;
const coords = point.coordinates;
// Add height = 0 if no height provided
if (coords.length === 2) {
coords[2] = 0;
}
if (isJsonNumber(this.czmlTemplate?.heightOffset)) {
coords[2] += this.czmlTemplate!.heightOffset;
}
czml.position = {
cartographicDegrees: point.coordinates
};
czml.properties = Object.assign(
czml.properties ?? {},
stringifyFeatureProperties(feature.properties ?? {})
);
rootCzml.push(czml);
} else if (
(feature.geometry?.type === "Polygon" ||
feature.geometry?.type === "MultiPolygon") &&
czmlTemplate?.polygon
) {
const czml = clone(czmlTemplate ?? {}, true);
// To handle both Polygon and MultiPolygon - transform Polygon coords into MultiPolygon coords
const multiPolygonGeom =
feature.geometry?.type === "Polygon"
? [(feature.geometry as Polygon).coordinates]
: (feature.geometry as MultiPolygon).coordinates;
// Loop through Polygons in MultiPolygon
for (let j = 0; j < multiPolygonGeom.length; j++) {
const geom = multiPolygonGeom[j];
const positions: number[] = [];
const holes: number[][] = [];
geom[0].forEach((coords) => {
if (isJsonNumber(this.czmlTemplate?.heightOffset)) {
coords[2] = (coords[2] ?? 0) + this.czmlTemplate!.heightOffset;
}
positions.push(coords[0], coords[1], coords[2]);
});
geom.forEach((ring, idx) => {
if (idx === 0) return;
holes.push(
ring.reduce<number[]>((acc, current) => {
if (isJsonNumber(this.czmlTemplate?.heightOffset)) {
current[2] =
(current[2] ?? 0) + this.czmlTemplate!.heightOffset;
}
acc.push(current[0], current[1], current[2]);
return acc;
}, [])
);
});
czml.polygon.positions = { cartographicDegrees: positions };
czml.polygon.holes = { cartographicDegrees: holes };
czml.properties = Object.assign(
czml.properties ?? {},
stringifyFeatureProperties(feature.properties ?? {})
);
rootCzml.push(czml);
}
} else if (
(feature?.geometry?.type === "LineString" ||
feature.geometry?.type === "MultiLineString") &&
(czmlTemplate?.polyline ||
czmlTemplate?.polylineVolume ||
czmlTemplate?.wall ||
czmlTemplate?.corridor)
) {
const czml = clone(czmlTemplate ?? {}, true);
// To handle both Polygon and MultiPolygon - transform Polygon coords into MultiPolygon coords
const multiLineString =
feature.geometry?.type === "LineString"
? [(feature.geometry as LineString).coordinates]
: (feature.geometry as MultiLineString).coordinates;
// Loop through Polygons in MultiPolygon
for (let j = 0; j < multiLineString.length; j++) {
const geom = multiLineString[j];
const positions: number[] = [];
geom.forEach((coords) => {
if (isJsonNumber(this.czmlTemplate?.heightOffset)) {
coords[2] = (coords[2] ?? 0) + this.czmlTemplate!.heightOffset;
}
positions.push(coords[0], coords[1], coords[2]);
});
// Add positions to all CZML line like features
if (czml.polyline) {
czml.polyline.positions = { cartographicDegrees: positions };
}
if (czml.polylineVolume) {
czml.polylineVolume.positions = {
cartographicDegrees: positions
};
}
if (czml.wall) {
czml.wall.positions = { cartographicDegrees: positions };
}
if (czml.corridor) {
czml.corridor.positions = { cartographicDegrees: positions };
}
czml.properties = Object.assign(
czml.properties ?? {},
stringifyFeatureProperties(feature.properties ?? {})
);
rootCzml.push(czml);
}
}
}
return CzmlDataSource.load(rootCzml);
}
@computed get defaultStyles() {
return {
markerSize: 24,
markerColor: getRandomCssColor(this.name ?? ""),
stroke: getColor(this.terria.baseMapContrastColor),
markerStroke: getColor(this.terria.baseMapContrastColor),
polygonStroke: getColor(this.terria.baseMapContrastColor),
polylineStroke: getRandomCssColor(this.name ?? ""),
markerStrokeWidth: 1,
polylineStrokeWidth: 2,
polygonStrokeWidth: 1,
fill: getRandomCssColor((this.name ?? "") + " fill"),
fillAlpha: 0.75
};
}
/** Applies default values on top of GeoJson StyleTraits. This is only used for Cesium Primitives.*/
@computed
get stylesWithDefaults() {
const defaultColor = (
colString: string | undefined,
defaultColor: Color
) => (colString ? getColor(colString) : defaultColor);
const options = {
describe: describeWithoutUnderscores,
markerSize:
parseMarkerSize(this.style["marker-size"]) ??
this.defaultStyles.markerSize,
markerSymbol: this.style["marker-symbol"], // and undefined if none
markerColor: defaultColor(
this.style["marker-color"],
this.defaultStyles.markerColor
),
stroke: defaultColor(this.style.stroke, this.defaultStyles.stroke),
polygonStroke: defaultColor(
this.style["polygon-stroke"] ?? this.style.stroke,
this.defaultStyles.polygonStroke
),
// Note these specific stroke widths are only used for geojson-vt
polylineStroke: defaultColor(
this.style["polyline-stroke"] ?? this.style.stroke,
this.defaultStyles.polylineStroke
),
markerStroke: defaultColor(
this.style["marker-stroke"] ?? this.style.stroke,
this.defaultStyles.markerStroke
),
markerStrokeWidth:
this.style["marker-stroke-width"] ??
this.style["stroke-width"] ??
this.defaultStyles.markerStrokeWidth,
polylineStrokeWidth:
this.style["polyline-stroke-width"] ??
this.style["stroke-width"] ??
this.defaultStyles.polylineStrokeWidth,
polygonStrokeWidth:
this.style["polygon-stroke-width"] ??
this.style["stroke-width"] ??
this.defaultStyles.polygonStrokeWidth,
markerOpacity: this.style["marker-opacity"], // not in SimpleStyle spec or supported by Cesium but see below
fill: defaultColor(this.style.fill, this.defaultStyles.fill),
clampToGround: this.clampToGround,
markerUrl: this.style["marker-url"] // not in SimpleStyle spec but gives an alternate to maki marker symbols
? proxyCatalogItemUrl(this, this.style["marker-url"])
: undefined,
credit: this.attribution
};
if (isDefined(this.style["stroke-opacity"])) {
options.stroke.alpha = this.style["stroke-opacity"];
options.polygonStroke.alpha = this.style["stroke-opacity"];
options.polylineStroke.alpha = this.style["stroke-opacity"];
options.markerStroke.alpha = this.style["stroke-opacity"];
}
if (isDefined(this.style["fill-opacity"])) {
options.fill.alpha = this.style["fill-opacity"];
} else {
options.fill.alpha = this.defaultStyles.fillAlpha;
}
return toJS(options);
}
protected async loadGeoJsonDataSource(
geoJson: FeatureCollectionWithCrs
): Promise<GeoJsonDataSource> {
/* Style information is applied as follows, in decreasing priority:
- simple-style properties set directly on individual features in the GeoJSON file
- simple-style properties set as the 'Style' property on the catalog item
- our 'this.styles' set below (and point styling applied after Cesium loads the GeoJSON)
- if anything is underspecified there, then Cesium's defaults come in.
See https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0
*/
this.addPerPropertyStyleToGeoJson(geoJson);
const now = JulianDate.now();
const styles = runInAction(() => this.stylesWithDefaults);
const dataSource = await GeoJsonDataSource.load(geoJson, styles);
const entities = dataSource.entities;
for (let i = 0; i < entities.values.length; ++i) {
const entity = entities.values[i];
const properties = entity.properties;
// Time
if (
isDefined(properties) &&
isDefined(this.timeProperty) &&
isDefined(this.discreteTimesAsSortedJulianDates)
) {
const startTimeDiscreteTime = properties[this.timeProperty];
const startTimeIdx = this.discreteTimesAsSortedJulianDates?.findIndex(
(t) => t.tag === startTimeDiscreteTime.getValue()
);
const startTime = this.discreteTimesAsSortedJulianDates[startTimeIdx];
if (isDefined(startTime)) {
const endTimeIdx = startTimeIdx + 1;
const endTime = this.discreteTimesAsSortedJulianDates[endTimeIdx];
entity.availability = new TimeIntervalCollection([
new TimeInterval({
start: startTime.time,
stop: endTime?.time ?? Iso8601.MAXIMUM_VALUE,
isStopIncluded: false
})
]);
}
}
// Billboard
if (isDefined(entity.billboard) && isDefined(styles.markerUrl)) {
entity.billboard = new BillboardGraphics({
image: new ConstantProperty(styles.markerUrl),
width:
properties && properties["marker-width"]
? new ConstantProperty(properties["marker-width"])
: undefined,
height:
properties && properties["marker-height"]
? new ConstantProperty(properties["marker-height"])
: undefined,
rotation:
properties && properties["marker-angle"]
? new ConstantProperty(properties["marker-angle"])
: undefined,
heightReference: styles.clampToGround
? new ConstantProperty(HeightReference.RELATIVE_TO_GROUND)
: undefined
});
/* If no marker symbol was provided but Cesium has generated one for a point, then turn it into
a filled circle instead of the default marker. */
} else if (
isDefined(entity.billboard) &&
(!properties || !isDefined(properties["marker-symbol"])) &&
!isDefined(styles.markerSymbol)
) {
entity.point = new PointGraphics({
color: new ConstantProperty(
getColor(
properties?.["marker-color"]?.getValue() ?? styles.markerColor
)
),
pixelSize: new ConstantProperty(
parseMarkerSize(
properties && properties["marker-size"]?.getValue()
) ?? styles.markerSize / 2
),
outlineWidth: new ConstantProperty(
properties?.["stroke-width"]?.getValue() ??
styles.markerStrokeWidth
),
outlineColor: new ConstantProperty(
getColor(properties?.stroke?.getValue() ?? styles.polygonStroke)
),
heightReference: new ConstantProperty(
styles.clampToGround
? HeightReference.RELATIVE_TO_GROUND
: undefined
),
disableDepthTestDistance: this.disableDepthTest
? new ConstantProperty(Number.POSITIVE_INFINITY)
: undefined
});
if (
properties &&
isDefined(properties["marker-opacity"]) &&
entity.point.color
) {
// not part of SimpleStyle spec, but why not?
const color: Color = entity.point.color.getValue(now);
color.alpha = parseFloat(properties["marker-opacity"]?.getValue());
}
entity.billboard = undefined;
}
if (
isDefined(entity.billboard) &&
properties &&
isDefined(properties["marker-opacity"]?.getValue())
) {
entity.billboard.color = new ConstantProperty(
new Color(
1,
1,
1,
parseFloat(properties["marker-opacity"]?.getValue())
)
);
}
if (isDefined(entity.polygon)) {
// Extrude polygons if heightProperty is set
if (
this.heightProperty &&
properties &&
isDefined(properties[this.heightProperty])
) {
entity.polygon.closeTop = new ConstantProperty(true);
entity.polygon.extrudedHeight = properties[this.heightProperty];
entity.polygon.heightReference = new ConstantProperty(
HeightReference.CLAMP_TO_GROUND
);
entity.polygon.extrudedHeightReference = new ConstantProperty(
HeightReference.RELATIVE_TO_GROUND
);
}
// Cesium on Windows can't render polygons with a stroke-width > 1.0. And even on other platforms it
// looks bad because WebGL doesn't mitre the lines together nicely.
// As a workaround for the special case where the polygon is unfilled anyway, change it to a polyline.
else if (
polygonHasWideOutline(entity.polygon, now) &&
!polygonIsFilled(entity.polygon)
) {
createPolylineFromPolygon(entities, entity, now);
entity.polygon = undefined;
} else if (
polygonHasOutline(entity.polygon, now) &&
isPolygonOnTerrain(entity.polygon, now)
) {
// Polygons don't directly support outlines when they're on terrain.
// So create a manual outline.
createPolylineFromPolygon(entities, entity, now);
}
}
}
return dataSource;
}
@override
get discreteTimes(): DiscreteTimeAsJS[] | undefined {
if (this.readyData === undefined) {
return undefined;
}
// If we are using mvt (mapbox vector tiles / protomaps imagery provider) return TableMixin.discreteTimes
if (this.useTableStylingAndProtomaps) return super.discreteTimes;
// If using timeProperty - get discrete times from that
if (this.timeProperty) {
const discreteTimesMap: Map<string, DiscreteTimeAsJS> = new Map();
for (let i = 0; i < this.readyData.features.length; i++) {
const feature = this.readyData.features[i];
if (
feature.properties !== null &&
feature.properties !== undefined &&
feature.properties[this.timeProperty!] !== undefined
) {
const dt = {
time: new Date(
`${feature.properties[this.timeProperty!]}`
).toISOString(),
tag: feature.properties[this.timeProperty!]
};
discreteTimesMap.set(dt.tag, dt);
}
}
return Array.from(discreteTimesMap.values());
}
}
/**
* Transform feature properties into column-major format.
* This enables all TableMixin functionality - which is used for styling vector tiles.
* If this returns an empty array, TableMixin will effectively be disabled
*/
@override
get dataColumnMajor() {
if (!this.readyData || !this.useTableStylingAndProtomaps) return [];
// Map from property name (column name) to column index
const colMap = new Map<string, number>();
const dataColumnMajor: string[][] = [];
dataColumnMajor[0] = new Array(this.readyData.features.length + 1).fill(
""
);
for (let i = 0; i < this.readyData.features.length; i++) {
const feature = this.readyData.features[i];
// Loop through feature properties
if (feature.properties) {
for (let j = 0; j < Object.keys(feature.properties).length; j++) {
const prop = Object.keys(feature.properties)[j];
const value = feature.properties[prop];
let colIndex = colMap.get(prop);
// If column isn't in colMap - we need to create it
if (!isDefined(colIndex)) {
colIndex = colMap.size;
colMap.set(prop, colIndex);
dataColumnMajor[colIndex] = new Array(
this.readyData.features.length + 1
).fill("");
}
if (typeof value === "string") {
dataColumnMajor[colIndex][i + 1] = value;
} else if (typeof value === "number") {
dataColumnMajor[colIndex][i + 1] = value.toString();
}
}
}
}
// Set column titles
colMap.forEach((index, prop) => {
dataColumnMajor[index][0] = prop;
});
return dataColumnMajor;
}
/** We don't need to use TableMixin forceLoadTableData
* We implement `get dataColumnMajor()` instead
*/
async forceLoadTableData() {
return undefined;
}
@override
get viewingControls(): ViewingControl[] {
return !this.useTableStylingAndProtomaps
? super.viewingControls.filter(
(v) => v.id !== TableStylingWorkflow.type
)
: super.viewingControls;
}
}
return GeoJsonMixin;
}
namespace GeoJsonMixin {
export interface Instance extends InstanceType<
ReturnType<typeof GeoJsonMixin>
> {}
export function isMixedInto(model: any): model is Instance {
return model && model.isGeoJson;
}
}
export default GeoJsonMixin;
function createPolylineFromPolygon(
entities: EntityCollection,
entity: Entity,
now: JulianDate
) {
const polygon = entity.polygon!;
entity.polyline = new PolylineGraphics();
entity.polyline.show = polygon.show;
if (isPolygonOnTerrain(polygon, now)) {
entity.polyline.clampToGround = new ConstantProperty(true);
}
if (isDefined(polygon.outlineColor)) {
entity.polyline.material = new ColorMaterialProperty(polygon.outlineColor);
}
const hierarchy: PolygonHierarchy | undefined = getPropertyValue(
polygon.hierarchy
);
if (!hierarchy) {
return;
}
const positions = closePolyline(hierarchy.positions);
entity.polyline.positions = new ConstantProperty(positions);
entity.polyline.width =
polygon.outlineWidth && polygon.outlineWidth.getValue(now);
createEntitiesFromHoles(entities, hierarchy.holes, entity);
}
export async function reprojectToGeographic(
geoJson: FeatureCollectionWithCrs,
proj4ServiceBaseUrl?: string
): Promise<FeatureCollectionWithCrs> {
let code: string | undefined;
if (!isJsonObject(geoJson.crs)) {
code = undefined;
} else if (
geoJson.crs.type === "EPSG" &&
isJsonObject(geoJson.crs.properties) &&
typeof geoJson.crs.properties.code === "string"
) {
code = "EPSG:" + geoJson.crs.properties.code;
} else if (
isJsonObject(geoJson.crs.properties) &&
geoJson.crs.type === "name" &&
typeof geoJson.crs.properties.name === "string"
) {
code = Reproject.crsStringToCode(geoJson.crs.properties.name);
}
geoJson.crs = {
type: "EPSG",
properties: {
code: "4326"
}
};
if (!code || !Reproject.willNeedReprojecting(code)) {
return Promise.resolve(geoJson);
}
const needsReprojection = proj4ServiceBaseUrl
? await Reproject.checkProjection(proj4ServiceBaseUrl, code)
: false;
if (needsReprojection) {
try {
filterValue(geoJson, "coordinates", function (obj, prop) {
obj[prop] = filterArray(obj[prop], function (pts) {
if (pts.length === 0) return [];
return reprojectPointList(pts, code);
});
});
return geoJson;
} catch (e) {
throw TerriaError.from(e, "Failed to reproject geoJSON");
}
} else {
throw new DeveloperError(
"The crs code for this datasource is unsupported."
);
}
}
type Coordinates = number[];
// Reproject a point list based on the supplied crs code.
function reprojectPointList(
pts: Coordinates | Coordinates[],
code?: string
): Coordinates | Coordinates[] {
if (!code) return [];
if (!Array.isArray(pts[0])) {
return Reproject.reprojectPoint(pts as any, code, "EPSG:4326") ?? [];
}
const pts_out = [];
for (let i = 0; i < pts.length; i++) {
const pt = pts[i];
if (Array.isArray(pt))
pts_out.push(
Reproject.reprojectPoint(pt as any, code, "EPSG:4326") ?? []
);
}
return pts_out;
}
// Find a member by name in the gml.
function filterValue(
obj: any,
prop: string,
func: (obj: any, prop: string) => void
) {
for (const p in obj) {
if (Object.hasOwnProperty.call(obj, p) === false) {
continue;
} else if (p === prop) {
if (func && typeof func === "function") {
func(obj, prop);
}
} else if (typeof obj[p] === "object") {
filterValue(obj[p], prop, func);
}
}
}
// Filter a geojson coordinates array structure.
function filterArray(
pts: any[],
func: (pts: Coordinates | Coordinates[]) => any
) {
if (!(pts[0] instanceof Array) || !(pts[0][0] instanceof Array)) {
pts = func(pts);
return pts;
}
const result = new Array(pts.length);
for (let i = 0; i < pts.length; i++) {
result[i] = filterArray(pts[i], func); // at array of arrays of points
}
return result;
}
/**
* Get a random color for the data based on the passed string (usually dataset name).
*/
function getRandomCssColor(
name: string,
cssColors: string[] = StandardCssColors.highContrast
) {
const index = hashFromString(name) % cssColors.length;
const color = Color.fromCssColorString(cssColors[index]);
color.alpha = 1;
return color;
}
const simpleStyleIdentifiers = [
"title",
"description",
"marker-size",
"marker-symbol",
"marker-color",
"stroke",
"stroke-opacity",
"stroke-width",
"fill",
"fill-opacity"
];
// This next function modelled on Cesium.geoJsonDataSource's defaultDescribe.
function describeWithoutUnderscores(
properties: any,
nameProperty?: string
): string {
let html = "";
for (let key in properties) {
if (Object.hasOwnProperty.call(properties, key