UNPKG

terriajs

Version:

Geospatial data visualization platform.

1,476 lines (1,305 loc) 55.7 kB
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