diff --git a/CHANGES.md b/CHANGES.md index e3233e787d2..1ee97136874 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,12 @@ - Fix `WebMapServiceCatalogItem` `allowFeaturePicking` - Allow translation of TableStylingWorkflow. - Fix "Remove all" not removing selected/picked features +- Fix crash on empty GeoJSON features +- Add `tableFeatureInfoContext` support to `GeoJsonMixin.createProtomapsImageryProvider` +- Fix `GeoJsonMixin` timeline animation for lines/polygons +- Fix bug in mismatched GeoJSON Feature `_id_` and TableMixin `rowId` - this was causing incorrect styling when using `filterByProperties` or features had `null` geometry +- Fix splitter for `GeoJsonMixin` (lines and polygon features only) +- Fix share links with picked features from `ProtomapsImageryProvider` - [The next improvement] #### 8.3.6 - 2023-10-03 diff --git a/lib/Map/ImageryProvider/ProtomapsImageryProvider.ts b/lib/Map/ImageryProvider/ProtomapsImageryProvider.ts index b4a7a546144..20fd81084c6 100644 --- a/lib/Map/ImageryProvider/ProtomapsImageryProvider.ts +++ b/lib/Map/ImageryProvider/ProtomapsImageryProvider.ts @@ -5,36 +5,35 @@ import circle from "@turf/circle"; import { Feature } from "@turf/helpers"; import i18next from "i18next"; import { cloneDeep, isEmpty } from "lodash-es"; -import { action, observable, runInAction, makeObservable } from "mobx"; +import { action, makeObservable, observable, runInAction } from "mobx"; import { Bbox, - Feature as ProtomapsFeature, GeomType, - Labelers, LabelRule, + Labelers, LineSymbolizer, - painter, + Rule as PaintRule, PmtilesSource, PreparedTile, - Rule as PaintRule, + Feature as ProtomapsFeature, TileCache, TileSource, View, Zxy, - ZxySource + ZxySource, + painter } from "protomaps"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import Credit from "terriajs-cesium/Source/Core/Credit"; -import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; import DeveloperError from "terriajs-cesium/Source/Core/DeveloperError"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; +import defaultValue from "terriajs-cesium/Source/Core/defaultValue"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; -import filterOutUndefined from "../../Core/filterOutUndefined"; -import isDefined from "../../Core/isDefined"; import TerriaError from "../../Core/TerriaError"; +import isDefined from "../../Core/isDefined"; import { FeatureCollectionWithCrs, FEATURE_ID_PROP as GEOJSON_FEATURE_ID_PROP, @@ -85,6 +84,8 @@ export type ProtomapsData = string | FeatureCollectionWithCrs | Source; interface Options { terria: Terria; + /** This must be defined to support pickedFeatures in share links */ + id?: string; data: ProtomapsData; minimumZoom?: number; maximumZoom?: number; @@ -96,6 +97,10 @@ interface Options { /** The name of the property that is a unique ID for features */ idProperty?: string; + + processPickedFeatures?: ( + features: ImageryLayerFeatureInfo[] + ) => Promise; } /** Buffer (in pixels) used when rendering (and generating - through geojson-vt) vector tiles */ @@ -268,6 +273,10 @@ export default class ProtomapsImageryProvider readonly errorEvent = new CesiumEvent(); readonly ready = true; readonly credit: Credit; + /** This is only used for Terria feature picking - as we track ImageryProvider feature picking by url (See PickedFeatures/Cesium._attachProviderCoordHooks). This URL is never called. + * This is set using the `id` property in the constructor options + */ + readonly url?: string; // Set values to please poor cesium types readonly defaultNightAlpha = undefined; @@ -288,11 +297,14 @@ export default class ProtomapsImageryProvider // Protomaps properties /** Data object from constructor options (this is transformed into `source`) */ private readonly data: ProtomapsData; - readonly maximumNativeZoom: number; private readonly labelers: Labelers; private readonly view: View | undefined; - readonly idProperty: string; + private readonly processPickedFeatures?: ( + features: ImageryLayerFeatureInfo[] + ) => Promise; + readonly maximumNativeZoom: number; + readonly idProperty: string; readonly source: Source; readonly paintRules: PaintRule[]; readonly labelRules: LabelRule[]; @@ -342,6 +354,7 @@ export default class ProtomapsImageryProvider } this.errorEvent = new CesiumEvent(); + this.url = options.id; this.ready = true; @@ -399,6 +412,8 @@ export default class ProtomapsImageryProvider 16, () => undefined ); + + this.processPickedFeatures = options.processPickedFeatures; } getTileCredits(x: number, y: number, level: number): Credit[] { @@ -490,6 +505,7 @@ export default class ProtomapsImageryProvider longitude: number, latitude: number ): Promise { + const featureInfos: ImageryLayerFeatureInfo[] = []; // If view is set - this means we are using actual vector tiles (that is not GeoJson object) // So we use this.view.queryFeatures if (this.view) { @@ -498,47 +514,48 @@ export default class ProtomapsImageryProvider (r) => r.dataLayer ); - return filterOutUndefined( - this.view - .queryFeatures( - CesiumMath.toDegrees(longitude), - CesiumMath.toDegrees(latitude), - level + this.view + .queryFeatures( + CesiumMath.toDegrees(longitude), + CesiumMath.toDegrees(latitude), + level + ) + .forEach((f) => { + // Only create FeatureInfo for visible features with properties + if ( + !f.feature.props || + isEmpty(f.feature.props) || + !renderedLayers.includes(f.layerName) ) - .map((f) => { - // Only create FeatureInfo for visible features with properties - if ( - !f.feature.props || - isEmpty(f.feature.props) || - !renderedLayers.includes(f.layerName) - ) - return; - - const featureInfo = new ImageryLayerFeatureInfo(); - - // Add Layer name property - featureInfo.properties = Object.assign( - { [LAYER_NAME_PROP]: f.layerName }, - f.feature.props ?? {} - ); - featureInfo.position = new Cartographic(longitude, latitude); + return; - featureInfo.configureDescriptionFromProperties(f.feature.props); - featureInfo.configureNameFromProperties(f.feature.props); + const featureInfo = new ImageryLayerFeatureInfo(); + + // Add Layer name property + featureInfo.properties = Object.assign( + { [LAYER_NAME_PROP]: f.layerName }, + f.feature.props ?? {} + ); + featureInfo.position = new Cartographic(longitude, latitude); + + featureInfo.configureDescriptionFromProperties(f.feature.props); + featureInfo.configureNameFromProperties(f.feature.props); + + featureInfos.push(featureInfo); + }); - return featureInfo; - }) - ); // No view is set and we have geoJSON object // So we pick features manually } else if ( this.source instanceof GeojsonSource && this.source.geojsonObject ) { + // Get rough meters per pixel (at equator) for given zoom level + const zoomMeters = 156543 / Math.pow(2, level); // Create circle with 10 pixel radius to pick features const buffer = circle( [CesiumMath.toDegrees(longitude), CesiumMath.toDegrees(latitude)], - 10 * this.terria.mainViewer.scale, + 10 * zoomMeters, { steps: 10, units: "meters" @@ -556,12 +573,12 @@ export default class ProtomapsImageryProvider const bufferBbox = bbox(buffer); // Get array of all features - let features: Feature[] = this.source.geojsonObject.features; + let geojsonFeatures: Feature[] = this.source.geojsonObject.features; const pickedFeatures: Feature[] = []; - for (let index = 0; index < features.length; index++) { - const feature = features[index]; + for (let index = 0; index < geojsonFeatures.length; index++) { + const feature = geojsonFeatures[index]; if (!feature.bbox) { feature.bbox = bbox(feature); } @@ -591,7 +608,7 @@ export default class ProtomapsImageryProvider } // Convert pickedFeatures to ImageryLayerFeatureInfos - return pickedFeatures.map((f) => { + pickedFeatures.forEach((f) => { const featureInfo = new ImageryLayerFeatureInfo(); featureInfo.data = f; @@ -611,10 +628,15 @@ export default class ProtomapsImageryProvider featureInfo.configureDescriptionFromProperties(f.properties); featureInfo.configureNameFromProperties(f.properties); - return featureInfo; + featureInfos.push(featureInfo); }); } - return []; + + if (this.processPickedFeatures) { + return await this.processPickedFeatures(featureInfos); + } + + return featureInfos; } private clone(options?: Partial) { @@ -648,6 +670,7 @@ export default class ProtomapsImageryProvider return new ProtomapsImageryProvider({ terria: options?.terria ?? this.terria, + id: options?.id ?? this.url, data, minimumZoom: options?.minimumZoom ?? this.minimumLevel, maximumZoom: options?.maximumZoom ?? this.maximumLevel, @@ -655,7 +678,9 @@ export default class ProtomapsImageryProvider rectangle: options?.rectangle ?? this.rectangle, credit: options?.credit ?? this.credit, paintRules: options?.paintRules ?? this.paintRules, - labelRules: options?.labelRules ?? this.labelRules + labelRules: options?.labelRules ?? this.labelRules, + processPickedFeatures: + options?.processPickedFeatures ?? this.processPickedFeatures }); } diff --git a/lib/ModelMixins/GeojsonMixin.ts b/lib/ModelMixins/GeojsonMixin.ts index 902b133fa09..14684953008 100644 --- a/lib/ModelMixins/GeojsonMixin.ts +++ b/lib/ModelMixins/GeojsonMixin.ts @@ -18,21 +18,21 @@ import { action, computed, IReactionDisposer, + makeObservable, observable, onBecomeObserved, onBecomeUnobserved, + override, reaction, runInAction, - toJS, - makeObservable, - override + toJS } from "mobx"; import { createTransformer } from "mobx-utils"; import { - Feature as ProtomapsFeature, GeomType, LineSymbolizer, - PolygonSymbolizer + PolygonSymbolizer, + Feature as ProtomapsFeature } from "protomaps"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; @@ -58,12 +58,14 @@ 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 hashFromString from "../Core/hashFromString"; import isDefined from "../Core/isDefined"; import { + isJsonArray, isJsonNumber, isJsonObject, isJsonString, @@ -73,8 +75,8 @@ import { isJson } from "../Core/loadBlob"; import StandardCssColors from "../Core/StandardCssColors"; import TerriaError, { networkRequestError } from "../Core/TerriaError"; import ProtomapsImageryProvider, { - GeojsonSource, GEOJSON_SOURCE_LAYER_NAME, + GeojsonSource, ProtomapsData } from "../Map/ImageryProvider/ProtomapsImageryProvider"; import Reproject from "../Map/Vector/Reproject"; @@ -86,11 +88,12 @@ 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 from "../Table/TableStyle"; +import TableStyle, { createRowGroupId } from "../Table/TableStyle"; import { isConstantStyleMap } from "../Table/TableStyleMap"; import { GeoJsonTraits } from "../Traits/TraitsClasses/GeoJsonTraits"; import { RectangleTraits } from "../Traits/TraitsClasses/MappableTraits"; @@ -295,7 +298,7 @@ function GeoJsonMixin>(Base: T) { () => [ this.useTableStylingAndProtomaps, this.readyData, - this.currentTimeAsJulianDate, + this.currentDiscreteJulianDate, this.activeTableStyle.timeIntervals, this.activeTableStyle.colorMap, this.activeTableStyle.pointSizeMap, @@ -502,6 +505,7 @@ function GeoJsonMixin>(Base: T) { const features = geoJsonWgs84.features; geoJsonWgs84.features = []; + let currentFeatureId = 0; for (let i = 0; i < features.length; i++) { const feature = features[i]; @@ -509,6 +513,13 @@ function GeoJsonMixin>(Base: T) { 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 = {}; } @@ -528,7 +539,7 @@ function GeoJsonMixin>(Base: T) { // 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] = i; + properties[FEATURE_ID_PROP] = currentFeatureId; // Count features types if (feature.geometry.type === "Point") { @@ -553,11 +564,17 @@ function GeoJsonMixin>(Base: T) { } 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; - this._readyData = geoJsonWgs84; + if (featureCounts.total === 0) { + this._readyData = undefined; + } else { + this._readyData = geoJsonWgs84; + } }); if (isDefined(czmlTemplate)) { @@ -758,6 +775,7 @@ function GeoJsonMixin>(Base: T) { let provider = new ProtomapsImageryProvider({ terria: this.terria, data: protomapsData, + id: this.uniqueId, paintRules: [ // Polygon features { @@ -800,7 +818,37 @@ function GeoJsonMixin>(Base: T) { } // See `createPoints` for Point features - they are handled by Cesium ], - labelRules: [] + 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); diff --git a/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts b/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts index 7e9ee34aefa..6d21833590f 100644 --- a/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts +++ b/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts @@ -206,6 +206,7 @@ class MapboxVectorTileCatalogItem extends MappableMixin( return new ProtomapsImageryProvider({ terria: this.terria, + id: this.uniqueId, data: this.url, minimumZoom: this.minimumZoom, maximumNativeZoom: this.maximumNativeZoom, diff --git a/lib/Table/TableAutomaticStylesStratum.ts b/lib/Table/TableAutomaticStylesStratum.ts index 0e111331f89..6c4705efe4e 100644 --- a/lib/Table/TableAutomaticStylesStratum.ts +++ b/lib/Table/TableAutomaticStylesStratum.ts @@ -18,6 +18,7 @@ import TableStyleTraits from "../Traits/TraitsClasses/Table/StyleTraits"; import TableTimeStyleTraits from "../Traits/TraitsClasses/Table/TimeStyleTraits"; import TableTraits from "../Traits/TraitsClasses/Table/TableTraits"; import TableColumnType from "./TableColumnType"; +import { ImageryParts } from "../ModelMixins/MappableMixin"; const DEFAULT_ID_COLUMN = "id"; @@ -55,9 +56,7 @@ export default class TableAutomaticStylesStratum extends LoadableStratum( @computed get disableSplitter() { - return !isDefined(this.catalogItem.activeTableStyle.regionColumn) - ? true - : undefined; + return !this.catalogItem.mapItems.find(ImageryParts.is) ? true : undefined; } /** diff --git a/lib/Table/TableStyle.ts b/lib/Table/TableStyle.ts index e6d26a1b0e2..77a7bd6dc41 100644 --- a/lib/Table/TableStyle.ts +++ b/lib/Table/TableStyle.ts @@ -605,9 +605,9 @@ export default class TableStyle { return finishDates; } - /** Get rows grouped by id. Id will be calculated using idColumns, latitude/longitude columns or region column + /** Columns used in rowGroups - idColumns, latitude/longitude columns or region column */ - @computed get rowGroups() { + @computed get groupByColumns() { let groupByCols = this.idColumns; if (!groupByCols) { @@ -618,22 +618,18 @@ export default class TableStyle { } else if (this.regionColumn) groupByCols = [this.regionColumn]; } - if (!groupByCols) groupByCols = []; + return groupByCols ?? []; + } + /** Get rows grouped by id. + */ + @computed get rowGroups() { const tableRowIds = this.tableModel.rowIds; return ( Object.entries( groupBy(tableRowIds, (rowId) => - groupByCols! - .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("-") + createRowGroupId(rowId, this.groupByColumns) ) ) // Filter out bad IDs @@ -746,6 +742,19 @@ export default class TableStyle { } } +/** Create row group ID by concatenating values for columns */ +export function createRowGroupId(rowId: number, columns: TableColumn[]) { + return columns + .map((col) => { + // If using region column as ID - only use valid regions + if (col.type === TableColumnType.region) { + return col.valuesAsRegions.regionIds[rowId]; + } + return col.values[rowId]; + }) + .join("-"); +} + /** * Returns an array of sorted unique dates */ diff --git a/test/Models/Catalog/CatalogItems/GeoJsonCatalogItemSpec.ts b/test/Models/Catalog/CatalogItems/GeoJsonCatalogItemSpec.ts index a2c62a71caf..3326b0705d4 100644 --- a/test/Models/Catalog/CatalogItems/GeoJsonCatalogItemSpec.ts +++ b/test/Models/Catalog/CatalogItems/GeoJsonCatalogItemSpec.ts @@ -1,11 +1,11 @@ -import { runInAction } from "mobx"; +import { reaction, runInAction } from "mobx"; import { GeomType, LineSymbolizer, PolygonSymbolizer } from "protomaps"; import { CustomDataSource } from "terriajs-cesium"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; -import createGuid from "terriajs-cesium/Source/Core/createGuid"; import Iso8601 from "terriajs-cesium/Source/Core/Iso8601"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; +import createGuid from "terriajs-cesium/Source/Core/createGuid"; import Entity from "terriajs-cesium/Source/DataSources/Entity"; import GeoJsonDataSource from "terriajs-cesium/Source/DataSources/GeoJsonDataSource"; import HeightReference from "terriajs-cesium/Source/Scene/HeightReference"; @@ -20,12 +20,19 @@ import { FEATURE_ID_PROP, getColor } from "../../../../lib/ModelMixins/GeojsonMixin"; -import { isDataSource } from "../../../../lib/ModelMixins/MappableMixin"; +import { + ImageryParts, + isDataSource +} from "../../../../lib/ModelMixins/MappableMixin"; import GeoJsonCatalogItem from "../../../../lib/Models/Catalog/CatalogItems/GeoJsonCatalogItem"; import SplitItemReference from "../../../../lib/Models/Catalog/CatalogReferences/SplitItemReference"; import CommonStrata from "../../../../lib/Models/Definition/CommonStrata"; import updateModelFromJson from "../../../../lib/Models/Definition/updateModelFromJson"; import TerriaFeature from "../../../../lib/Models/Feature/Feature"; +import { + TerriaFeatureData, + isTerriaFeatureData +} from "../../../../lib/Models/Feature/FeatureData"; import Terria from "../../../../lib/Models/Terria"; describe("GeoJsonCatalogItemSpec", () => { @@ -497,9 +504,7 @@ describe("GeoJsonCatalogItemSpec", () => { expect(entities.length).toEqual(1); const entity1 = entities[0]; - console.log( - entity1.properties?.getValue(terria.timelineClock.currentTime).year - ); + expect( entity1.properties?.getValue(terria.timelineClock.currentTime).year ).toBe(2019); @@ -823,6 +828,8 @@ describe("GeoJsonCatalogItemSpec", () => { ?.getValue(terria.timelineClock.currentTime) ?.toCssColorString() ).toBe("rgb(103,0,13)"); + + expect(geojson.disableSplitter).toBeTruthy(); }); it("Supports LegendOwnerTraits to override TableMixin.legends", async () => { @@ -922,12 +929,13 @@ describe("GeoJsonCatalogItemSpec", () => { geojson.setTrait( CommonStrata.user, "geoJsonString", - `{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"stroke":"#555555","stroke-width":2,"stroke-opacity":1,"fill":"#ff0051","fill-opacity":0.5},"geometry":{"type":"Polygon","coordinates":[[[35.859375,53.54030739150022],[11.25,40.17887331434696],[15.1171875,14.604847155053898],[53.4375,44.84029065139799],[35.859375,53.54030739150022]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[85.4296875,66.93006025862448],[53.4375,43.83452678223682],[89.296875,34.88593094075317],[91.40625,50.958426723359935],[85.4296875,66.93006025862448]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[119.17968749999999,66.79190947341796],[100.1953125,53.74871079689897],[109.3359375,47.517200697839414],[119.17968749999999,66.79190947341796]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Point","coordinates":[30.585937499999996,-2.108898659243126]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[71.015625,-2.811371193331128],[99.49218749999999,-2.811371193331128],[99.49218749999999,18.646245142670608],[71.015625,18.646245142670608],[71.015625,-2.811371193331128]]]}},{"type":"Feature","properties":{},"geometry":{"type":"LineString","coordinates":[[140.9765625,19.642587534013032],[134.296875,-17.978733095556155],[88.9453125,-36.597889133070204],[119.53125,15.961329081596647],[130.078125,27.371767300523047]]}}]}` + `{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"stroke":"#555555","stroke-width":2,"stroke-opacity":1,"fill":"#ff0051","fill-opacity":0.5},"geometry":{"type":"Polygon","coordinates":[[[35.859375,53.54030739150022],[11.25,40.17887331434696],[15.1171875,14.604847155053898],[53.4375,44.84029065139799],[35.859375,53.54030739150022]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[85.4296875,66.93006025862448],[53.4375,43.83452678223682],[89.296875,34.88593094075317],[91.40625,50.958426723359935],[85.4296875,66.93006025862448]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[119.17968749999999,66.79190947341796],[100.1953125,53.74871079689897],[109.3359375,47.517200697839414],[119.17968749999999,66.79190947341796]]]}},{"type":"Feature","properties":{},"geometry":{"type":"Polygon","coordinates":[[[71.015625,-2.811371193331128],[99.49218749999999,-2.811371193331128],[99.49218749999999,18.646245142670608],[71.015625,18.646245142670608],[71.015625,-2.811371193331128]]]}},{"type":"Feature","properties":{},"geometry":{"type":"LineString","coordinates":[[140.9765625,19.642587534013032],[134.296875,-17.978733095556155],[88.9453125,-36.597889133070204],[119.53125,15.961329081596647],[130.078125,27.371767300523047]]}}]}` ); await geojson.loadMapItems(); expect(geojson.mapItems[0] instanceof GeoJsonDataSource).toBeFalsy(); expect(geojson.useTableStylingAndProtomaps).toBeTruthy(); expect(geojson.legends.length).toBe(1); + expect(geojson.disableSplitter).toBeFalsy(); }); it("Disabled protomaps - More than 50% features detected", async () => { @@ -942,6 +950,143 @@ describe("GeoJsonCatalogItemSpec", () => { expect(geojson.useTableStylingAndProtomaps).toBeFalsy(); expect(geojson.legends.length).toBe(0); + expect(geojson.disableSplitter).toBeTruthy(); + }); + + it("correctly matches feature _id_ with table rowId - with features with empty geoms", async () => { + geojson.setTrait( + CommonStrata.user, + "url", + "test/GeoJSON/empty-geoms.geojson" + ); + + await geojson.loadMapItems(); + expect(geojson.readyData?.features.length).toBe(4); + // Check _id_ vs rowIds + expect( + geojson.readyData?.features.map((f) => f.properties?.[FEATURE_ID_PROP]) + ).toEqual(geojson.rowIds); + // Check "someOtherProp" column + expect( + geojson.readyData?.features.map((f) => f.properties?.someOtherProp) + ).toEqual( + geojson.tableColumns.find((c) => c.name === "someOtherProp") + ?.values as string[] + ); + }); + + it("correctly matches feature _id_ with table rowId - with filterByProperties", async () => { + geojson.setTrait( + CommonStrata.user, + "url", + "test/GeoJSON/time-based.geojson" + ); + geojson.setTrait(CommonStrata.user, "filterByProperties", { + year: 2019 + }); + await geojson.loadMapItems(); + + expect(geojson.readyData?.features.length).toBe(1); + expect( + geojson.readyData?.features.map((f) => f.properties?.[FEATURE_ID_PROP]) + ).toEqual(geojson.rowIds); + }); + + it("supports time", async function () { + geojson.setTrait( + CommonStrata.definition, + "url", + "test/GeoJSON/time-based-automatic-styles.geojson" + ); + + updateModelFromJson(geojson, CommonStrata.definition, { + currentTime: "2018-01-01", + defaultStyle: { + time: { timeColumn: "date", idColumns: ["idProperty"] } + } + }); + + const observeMapItems = reaction( + () => [geojson.mapItems], + () => {} + ); + + (await geojson.loadMapItems()).throwIfError(); + + expect(geojson.activeTableStyle.timeColumn?.name).toBe("date"); + + const firstProtomapsImageryProvider = + "imageryProvider" in geojson.mapItems[0] + ? (geojson.mapItems[0].imageryProvider as ProtomapsImageryProvider) + : undefined; + + if (!firstProtomapsImageryProvider) throw "protomaps should be defined"; + + const testFeature = { + props: {}, + geomType: GeomType.Polygon, + numVertices: 0, + geom: [], + bbox: { minX: 0, minY: 0, maxX: 0, maxY: 0 } + }; + + const firstFilter = firstProtomapsImageryProvider.paintRules[0].filter; + + if (!firstFilter) { + throw "filter should be defined"; + } + + // Current time is 2018-01-01 + // First feature maps to 2018-01-01 + testFeature.props = { [FEATURE_ID_PROP]: 0 }; + expect(firstFilter(0, testFeature)).toBeTruthy(); + + // Second feature maps to 2019-01-01 + testFeature.props = { [FEATURE_ID_PROP]: 1 }; + expect(firstFilter(0, testFeature)).toBeFalsy(); + + // Change time to 2019-01-01 + geojson.setTrait(CommonStrata.definition, "currentTime", "2019-01-01"); + + // Check new imagery provider + const nextProtomapsImageryProvider = + "imageryProvider" in geojson.mapItems[0] + ? (geojson.mapItems[0].imageryProvider as ProtomapsImageryProvider) + : undefined; + + if (!nextProtomapsImageryProvider) throw "protomaps should be defined"; + + const nextFilter = nextProtomapsImageryProvider.paintRules[0].filter; + + if (!nextFilter) { + throw "filter should be defined"; + } + + testFeature.props = { [FEATURE_ID_PROP]: 0 }; + expect(nextFilter(0, testFeature)).toBeFalsy(); + testFeature.props = { [FEATURE_ID_PROP]: 1 }; + expect(nextFilter(0, testFeature)).toBeTruthy(); + + expect( + firstProtomapsImageryProvider === nextProtomapsImageryProvider + ).toBeFalsy(); + + // Now change the currentTime to 2019- g01-02 - this should not trigger a new imagery provider - as it within the current time interval + geojson.setTrait(CommonStrata.definition, "currentTime", "2019-01-02"); + + // Check new imagery provider + const lastProtomapsImageryProvider = + "imageryProvider" in geojson.mapItems[0] + ? (geojson.mapItems[0].imageryProvider as ProtomapsImageryProvider) + : undefined; + + if (!lastProtomapsImageryProvider) throw "protomaps should be defined"; + + expect( + nextProtomapsImageryProvider === lastProtomapsImageryProvider + ).toBeTruthy(); + + observeMapItems(); }); }); @@ -986,12 +1131,14 @@ describe("GeoJsonCatalogItemSpec", () => { geojson = new GeoJsonCatalogItem("test-geojson", terria); }); - it("protomaps-mvt", async function () { + it("protomaps-mvt - polygons/lines", async function () { terria.addModel(geojson); - const geojsonString = await loadText("test/GeoJSON/cemeteries.geojson"); + const geojsonString = await loadText("test/GeoJSON/time-based.geojson"); geojson.setTrait(CommonStrata.user, "geoJsonString", geojsonString); await geojson.loadMapItems(); + expect(geojson.disableSplitter).toBeFalsy(); + const split = new SplitItemReference(createGuid(), terria); split.setTrait( CommonStrata.definition, @@ -1009,6 +1156,15 @@ describe("GeoJsonCatalogItemSpec", () => { (await (split.target as GeoJsonCatalogItem).loadMapItems()).error ).toBeUndefined(); }); + + it("cesium - points - splitter disabled", async function () { + terria.addModel(geojson); + const geojsonString = await loadText("test/GeoJSON/cemeteries.geojson"); + geojson.setTrait(CommonStrata.user, "geoJsonString", geojsonString); + await geojson.loadMapItems(); + + expect(geojson.disableSplitter).toBeTruthy(); + }); }); describe("geojson handles reprojection", function () { @@ -1218,5 +1374,123 @@ describe("GeoJsonCatalogItemSpec", () => { throw "Invalid geojson.mapItems"; } }); + + it("ProtomapsImageryProvider pickFeatures", async function () { + const geojsonData = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [ + [ + [145.5908203125, -40.17887331434695], + [143.349609375, -42.08191667830631], + [146.35986328124997, -44.040218713142124], + [149.08447265625, -42.859859815062784], + [148.55712890625, -41.36031866306708], + [145.5908203125, -40.17887331434695] + ] + ] + } + }, + { + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [ + [ + [75.9375, 51.069016659603896], + [59.94140624999999, 39.095962936305476], + [79.453125, 42.032974332441405], + [80.15625, 46.800059446787316], + [75.673828125, 51.45400691005982], + [75.9375, 51.069016659603896] + ] + ] + } + } + ] + }; + geojson.setTrait( + CommonStrata.definition, + "geoJsonString", + JSON.stringify(geojsonData) + ); + + (await geojson.loadMapItems()).throwIfError(); + + const imagery = geojson.mapItems[0] as ImageryParts; + + expect( + imagery.imageryProvider instanceof ProtomapsImageryProvider + ).toBeTruthy(); + + const spyOnProcessPickedFeatures = spyOn( + imagery.imageryProvider, + "pickFeatures" + ).and.callThrough(); + + const features = + (await imagery.imageryProvider.pickFeatures( + 1, + 1, + 3, + 1.2946797849754814, + 0.7826107094181278 + )) ?? []; + + expect(spyOnProcessPickedFeatures).toHaveBeenCalledTimes(1); + expect(features.length).toBe(1); + expect(features[0].data.geometry).toEqual( + geojsonData.features[1].geometry + ); + }); + + it("ProtomapsImageryProvider pickFeatures - with time", async function () { + geojson.setTrait( + CommonStrata.definition, + "url", + "test/GeoJSON/time-based-automatic-styles.geojson" + ); + + updateModelFromJson(geojson, CommonStrata.definition, { + defaultStyle: { + time: { timeColumn: "date", idColumns: ["idProperty"] } + } + }); + + (await geojson.loadMapItems()).throwIfError(); + + const imagery = geojson.mapItems[0] as ImageryParts; + + expect( + imagery.imageryProvider instanceof ProtomapsImageryProvider + ).toBeTruthy(); + + const spyOnProcessPickedFeatures = spyOn( + imagery.imageryProvider, + "pickFeatures" + ).and.callThrough(); + + const features = + (await imagery.imageryProvider.pickFeatures( + 59166, + 40202, + 16, + 2.5309053894540012, + -0.6590723957845167 + )) ?? []; + + expect(spyOnProcessPickedFeatures).toHaveBeenCalledTimes(1); + expect(features.length).toBe(1); + expect(isTerriaFeatureData(features[0].data)).toBeTruthy(); + + const terriaFeatureData = features[0].data as TerriaFeatureData; + expect(terriaFeatureData.rowIds).toEqual([4, 5, 6, 7, 8]); + }); }); }); diff --git a/wwwroot/test/GeoJSON/empty-geoms.geojson b/wwwroot/test/GeoJSON/empty-geoms.geojson new file mode 100644 index 00000000000..f327c7ae095 --- /dev/null +++ b/wwwroot/test/GeoJSON/empty-geoms.geojson @@ -0,0 +1,89 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "height": 10, + "radius": 10, + "someOtherProp": "what" + }, + "geometry": { + "type": "Point", + "coordinates": [144.91734981536865, -37.824700770115996] + } + }, + { + "type": "Feature", + "properties": { + "height": 20, + "radius": 5, + "someOtherProp": "ok" + }, + "geometry": { + "type": "Point", + "coordinates": [144.92305755615234, -37.82453127776299] + } + }, + { + "type": "Feature", + "properties": { + "someProperty": 10, + "someOtherProp": "hey" + }, + "geometry": { + "type": "Polygon", + "coordinates": [] + } + }, + { + "type": "Feature", + "properties": { + "someProperty": 10, + "someOtherProp": "yo" + }, + "geometry": null + }, + { + "type": "Feature", + "properties": { + "someProperty": 20, + "someOtherProp": "what" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [145.0100040435791, -37.76080849651723], + [145.00873804092407, -37.76342088777352], + [145.0157332420349, -37.76292895101701], + [145.0100040435791, -37.76080849651723] + ] + ] + } + }, + { + "type": "Feature", + "bbox": [-10.0, -10.0, 10.0, 10.0], + "properties": { + "foo": "hi", + "bar": "bye", + "stroke-width": 1, + "someOtherProp": "is" + }, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 1.0] + ], + [ + [102.0, 2.0], + [103.0, 3.0] + ] + ] + } + } + ] +} diff --git a/wwwroot/test/GeoJSON/time-based-automatic-styles.geojson b/wwwroot/test/GeoJSON/time-based-automatic-styles.geojson index cda41a1888a..8cd654ffe39 100644 --- a/wwwroot/test/GeoJSON/time-based-automatic-styles.geojson +++ b/wwwroot/test/GeoJSON/time-based-automatic-styles.geojson @@ -6,7 +6,7 @@ "properties": { "date": 2018, "someProperty": 3, - "id": 0 + "idProperty": 0 }, "geometry": { "type": "Polygon", @@ -26,7 +26,7 @@ "properties": { "date": 2019, "someProperty": 6, - "id": 0 + "idProperty": 0 }, "geometry": { "type": "Polygon", @@ -46,7 +46,7 @@ "properties": { "date": 2020, "someProperty": 10, - "id": 0 + "idProperty": 0 }, "geometry": { "type": "Polygon", @@ -66,7 +66,7 @@ "properties": { "date": 2021, "someProperty": 0, - "id": 0 + "idProperty": 0 }, "geometry": { "type": "Polygon", @@ -86,7 +86,7 @@ "properties": { "date": 2018, "someProperty": 3, - "id": 1 + "idProperty": 1 }, "geometry": { "type": "Polygon", @@ -105,7 +105,7 @@ "properties": { "date": 2019, "someProperty": 4, - "id": 1 + "idProperty": 1 }, "geometry": { "type": "Polygon", @@ -124,7 +124,7 @@ "properties": { "date": 2020, "someProperty": 1, - "id": 1 + "idProperty": 1 }, "geometry": { "type": "Polygon", @@ -143,7 +143,7 @@ "properties": { "date": 2021, "someProperty": 10, - "id": 1 + "idProperty": 1 }, "geometry": { "type": "Polygon", @@ -162,7 +162,7 @@ "properties": { "date": 2022, "someProperty": 7, - "id": 1 + "idProperty": 1 }, "geometry": { "type": "Polygon",