From ebc492bbf327ed80f5f11684686d1470f6038aab Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 5 May 2025 12:50:42 +1000 Subject: [PATCH 01/31] Fix @mapbox/togeojson typing. Folders in typeRoots should have depth of 1. --- .../{@mapbox/togeojson => mapbox__togeojson}/index.d.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/ThirdParty/{@mapbox/togeojson => mapbox__togeojson}/index.d.ts (100%) diff --git a/lib/ThirdParty/@mapbox/togeojson/index.d.ts b/lib/ThirdParty/mapbox__togeojson/index.d.ts similarity index 100% rename from lib/ThirdParty/@mapbox/togeojson/index.d.ts rename to lib/ThirdParty/mapbox__togeojson/index.d.ts From 79903804c8c7fcbf0d71bbc10630b956e84c09e8 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 10:11:33 +1000 Subject: [PATCH 02/31] Extract CrsTraits and parse CRS bounding boxes for WMS layers. --- .../Catalog/Ows/WebMapServiceCapabilities.ts | 53 +++++++++++ .../Ows/WebMapServiceCapabilitiesStratum.ts | 52 ++++++++++- lib/Traits/TraitsClasses/CrsTraits.ts | 90 +++++++++++++++++++ .../WebMapServiceCatalogItemTraits.ts | 16 +--- 4 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 lib/Traits/TraitsClasses/CrsTraits.ts diff --git a/lib/Models/Catalog/Ows/WebMapServiceCapabilities.ts b/lib/Models/Catalog/Ows/WebMapServiceCapabilities.ts index 8b4e10ada75..849f54da2a1 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCapabilities.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCapabilities.ts @@ -11,6 +11,7 @@ import { OwsKeywordList } from "./OwsInterfaces"; import StratumFromTraits from "../../Definition/StratumFromTraits"; +import filterOutUndefined from "../../../Core/filterOutUndefined"; export interface CapabilitiesGeographicBoundingBox { readonly westBoundLongitude: number; @@ -26,6 +27,15 @@ export interface CapabilitiesLatLonBoundingBox { readonly maxy: number; } +export interface BoundingBox { + readonly CRS?: string; + readonly crs?: string; + readonly minx?: string; + readonly miny?: string; + readonly maxx?: string; + readonly maxy?: string; +} + export type CapabilitiesDimension = string & { readonly name: string; readonly units: string; @@ -71,6 +81,7 @@ export interface CapabilitiesLayer { readonly MetadataURL?: MetadataURL | ReadonlyArray; readonly EX_GeographicBoundingBox?: CapabilitiesGeographicBoundingBox; // WMS 1.3.0 readonly LatLonBoundingBox?: CapabilitiesLatLonBoundingBox; // WMS 1.0.0-1.1.1 + readonly BoundingBox?: ReadonlyArray | BoundingBox; readonly Style?: CapabilitiesStyle | ReadonlyArray; readonly Layer?: CapabilitiesLayer | ReadonlyArray; readonly Dimension?: @@ -129,6 +140,48 @@ type ElementTypeIfArray = T extends ReadonlyArray ? U : T; type Mutable = { -readonly [P in keyof T]: T[P] }; +/** + * Get BoundingBox definitions for layer. + * + * A bounding box is usually defined for each supported CRS. + */ +export function getLayerBoundingBoxes( + layer: CapabilitiesLayer +): { crs: string; minx: number; miny: number; maxx: number; maxy: number }[] { + const boxes: BoundingBox[] = Array.isArray(layer.BoundingBox) + ? layer.BoundingBox + : layer.BoundingBox + ? [layer.BoundingBox] + : []; + + return filterOutUndefined( + boxes.map((box) => { + const crs = box.CRS ?? box.crs; + const [minx, miny, maxx, maxy] = [ + box.minx, + box.miny, + box.maxx, + box.maxy + ].map((x) => { + const num = x === undefined ? undefined : parseFloat(x); + return num === undefined || isNaN(num) ? undefined : num; + }); + + if ( + crs === undefined || + minx === undefined || + miny === undefined || + maxx === undefined || + maxy === undefined + ) { + return; + } + + return { crs, minx, miny, maxx, maxy }; + }) + ); +} + export function getRectangleFromLayer( layer: CapabilitiesLayer ): StratumFromTraits | undefined { diff --git a/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts b/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts index 3a828f9797d..4389c8f6ab6 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts @@ -1,3 +1,4 @@ +import dateFormat from "dateformat"; import i18next from "i18next"; import { computed, makeObservable } from "mobx"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; @@ -15,6 +16,7 @@ import { InfoSectionTraits, MetadataUrlTraits } from "../../../Traits/TraitsClasses/CatalogMemberTraits"; +import { ProjectedBoundingBoxTraits } from "../../../Traits/TraitsClasses/CrsTraits"; import { KeyValueTraits, WebCoverageServiceParameterTraits @@ -41,10 +43,10 @@ import WebMapServiceCapabilities, { CapabilitiesDimension, CapabilitiesLayer, MetadataURL, + getLayerBoundingBoxes, getRectangleFromLayer } from "./WebMapServiceCapabilities"; import WebMapServiceCatalogItem from "./WebMapServiceCatalogItem"; -import dateFormat from "dateformat"; /** Transforms WMS GetCapabilities XML into WebMapServiceCatalogItemTraits */ export default class WebMapServiceCapabilitiesStratum extends LoadableStratum( @@ -682,6 +684,54 @@ export default class WebMapServiceCapabilitiesStratum extends LoadableStratum( } } + /** + * Combined bounding boxes for active layers + * + * There is usually one bounding box for each supported CRS. + */ + @computed + get boundingBoxes() { + const layers: CapabilitiesLayer[] = [...this.capabilitiesLayers.values()] + .filter((layer) => layer !== undefined) + .map((l) => l!); + // Get union of bounding rectangles for all layers + + const crsBoxes: Record< + string, + { minx: number; miny: number; maxx: number; maxy: number } + > = {}; + + for (let layer of layers) { + const layerBoxes = getLayerBoundingBoxes(layer); + for (let { crs, minx, miny, maxx, maxy } of layerBoxes) { + if (crsBoxes[crs]) { + const current = crsBoxes[crs]; + crsBoxes[crs] = { + minx: Math.min(current.minx, minx), + miny: Math.min(current.miny, miny), + maxx: Math.max(current.maxx, maxx), + maxy: Math.max(current.maxy, maxy) + }; + } else { + crsBoxes[crs] = { + minx, + miny, + maxx, + maxy + }; + } + } + } + + return Object.entries(crsBoxes).map(([crs, box]) => + createStratumInstance(ProjectedBoundingBoxTraits, { + crs, + min: { x: box.minx, y: box.miny }, + max: { x: box.maxx, y: box.maxy } + }) + ); + } + @computed get isGeoServer(): boolean | undefined { const keyword = this.capabilities?.Service?.KeywordList?.Keyword; diff --git a/lib/Traits/TraitsClasses/CrsTraits.ts b/lib/Traits/TraitsClasses/CrsTraits.ts new file mode 100644 index 00000000000..f36d6f80033 --- /dev/null +++ b/lib/Traits/TraitsClasses/CrsTraits.ts @@ -0,0 +1,90 @@ +import objectArrayTrait from "../Decorators/objectArrayTrait"; +import objectTrait from "../Decorators/objectTrait"; +import primitiveArrayTrait from "../Decorators/primitiveArrayTrait"; +import primitiveTrait from "../Decorators/primitiveTrait"; +import ModelTraits from "../ModelTraits"; + +export const SUPPORTED_CRS_3857 = ["EPSG:3857", "EPSG:900913"]; +export const SUPPORTED_CRS_4326 = ["EPSG:4326", "CRS:84", "EPSG:4283"]; + +export class PointTraits extends ModelTraits { + @primitiveTrait({ + type: "number", + name: "X", + description: "X coordinate" + }) + x?: number; + + @primitiveTrait({ + type: "number", + name: "Y", + description: "Y coordinate" + }) + y?: number; +} + +export class ProjectedBoundingBoxTraits extends ModelTraits { + @primitiveTrait({ + type: "string", + name: "CRS", + description: "The CRS in which this boundibg box is defined" + }) + crs?: string; + + @objectTrait({ + type: PointTraits, + name: "min", + description: "Bounding box min point" + }) + min?: PointTraits; + + @objectTrait({ + type: PointTraits, + name: "max", + description: "Bounding box max point" + }) + max?: PointTraits; +} + +export default class CrsTraits extends ModelTraits { + @primitiveTrait({ + type: "string", + name: "CRS", + description: `CRS to use with WMS layers. We support Web Mercator (${SUPPORTED_CRS_3857.join( + ", " + )}) and WGS 84 (${SUPPORTED_CRS_4326.join(", ")})` + }) + crs?: string; + + @primitiveTrait({ + type: "string", + name: "Preview CRS", + description: `CRS to use with WMS layers. We support Web Mercator (${SUPPORTED_CRS_3857.join( + ", " + )}) and WGS 84 (${SUPPORTED_CRS_4326.join(", ")})` + }) + previewCrs?: string; + + @primitiveTrait({ + type: "string", + name: "Tiling scheme generator", + description: `Name of a registered tiling scheme generator to use. Plugins may set this to custom tiling scheme generator.` + }) + tilingSchemeGenerator?: string = "default"; + + @primitiveArrayTrait({ + type: "string", + name: "Available CRS", + description: + "A list of valid CRS for this dataset. This should be automatically discovered from the service metadata if available." + }) + availableCrs?: string[]; + + @objectArrayTrait({ + type: ProjectedBoundingBoxTraits, + idProperty: "crs", + name: "Bounding boxes", + description: "Native bounding boxes for supported CRS." + }) + boundingBoxes?: ProjectedBoundingBoxTraits[]; +} diff --git a/lib/Traits/TraitsClasses/WebMapServiceCatalogItemTraits.ts b/lib/Traits/TraitsClasses/WebMapServiceCatalogItemTraits.ts index 459858448ae..eaaddd30f81 100644 --- a/lib/Traits/TraitsClasses/WebMapServiceCatalogItemTraits.ts +++ b/lib/Traits/TraitsClasses/WebMapServiceCatalogItemTraits.ts @@ -8,6 +8,7 @@ import mixTraits from "../mixTraits"; import ModelTraits from "../ModelTraits"; import { traitClass } from "../Trait"; import CatalogMemberTraits from "./CatalogMemberTraits"; +import CrsTraits from "./CrsTraits"; import DiffableTraits from "./DiffableTraits"; import ExportWebCoverageServiceTraits from "./ExportWebCoverageServiceTraits"; import GetCapabilitiesTraits from "./GetCapabilitiesTraits"; @@ -19,8 +20,7 @@ import MappableTraits from "./MappableTraits"; import { MinMaxLevelTraits } from "./MinMaxLevelTraits"; import UrlTraits from "./UrlTraits"; -export const SUPPORTED_CRS_3857 = ["EPSG:3857", "EPSG:900913"]; -export const SUPPORTED_CRS_4326 = ["EPSG:4326", "CRS:84", "EPSG:4283"]; +export { SUPPORTED_CRS_3857, SUPPORTED_CRS_4326 } from "./CrsTraits"; export class WebMapServiceAvailableStyleTraits extends ModelTraits { @primitiveTrait({ @@ -175,7 +175,8 @@ export default class WebMapServiceCatalogItemTraits extends mixTraits( MappableTraits, CatalogMemberTraits, LegendOwnerTraits, - MinMaxLevelTraits + MinMaxLevelTraits, + CrsTraits ) { @primitiveTrait({ type: "string", @@ -192,15 +193,6 @@ export default class WebMapServiceCatalogItemTraits extends mixTraits( }) styles?: string; - @primitiveTrait({ - type: "string", - name: "Style(s)", - description: `CRS to use with WMS layers. We support Web Mercator (${SUPPORTED_CRS_3857.join( - ", " - )}) and WGS 84 (${SUPPORTED_CRS_4326.join(", ")})` - }) - crs?: string; - @anyTrait({ name: "Dimensions", description: From 2103be40ce7c404ace0de2d8dd8b7ec55317c24a Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 10:21:56 +1000 Subject: [PATCH 03/31] Add customizable TilingSchemeGenerator for use with plugins. --- .../ImageryProvider/TilingSchemeGenerator.ts | 72 +++++++++++++++++++ .../ImageryProviderLeafletTileLayer.ts | 63 +++++++++------- .../Catalog/Ows/WebMapServiceCatalogItem.ts | 32 ++++----- lib/Models/Cesium.ts | 10 +++ 4 files changed, 134 insertions(+), 43 deletions(-) create mode 100644 lib/Map/ImageryProvider/TilingSchemeGenerator.ts diff --git a/lib/Map/ImageryProvider/TilingSchemeGenerator.ts b/lib/Map/ImageryProvider/TilingSchemeGenerator.ts new file mode 100644 index 00000000000..52e3b49277b --- /dev/null +++ b/lib/Map/ImageryProvider/TilingSchemeGenerator.ts @@ -0,0 +1,72 @@ +import { observable } from "mobx"; +import GeographicTilingScheme from "terriajs-cesium/Source/Core/GeographicTilingScheme"; +import TilingScheme from "terriajs-cesium/Source/Core/TilingScheme"; +import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; +import { + SUPPORTED_CRS_3857, + SUPPORTED_CRS_4326 +} from "../../Traits/TraitsClasses/CrsTraits"; + +export interface TerriaTilingScheme extends TilingScheme { + // Custom tiling implementations can specify its CRS + customCrs?: string; +} + +export type TilingSchemeGeneratorFunction = ( + crs: string | undefined +) => TerriaTilingScheme | undefined; + +/** + * The default tiling scheme generator + */ +const defaultTilingSchemeGenerator = (crs: string | undefined) => { + if (crs) { + if (SUPPORTED_CRS_3857.includes(crs)) return new WebMercatorTilingScheme(); + if (SUPPORTED_CRS_4326.includes(crs)) return new GeographicTilingScheme(); + } + + return new WebMercatorTilingScheme(); +}; + +export default class TilingSchemeGenerator { + private static readonly _generators = observable.map< + string, + TilingSchemeGeneratorFunction + >(); + + static readonly default = defaultTilingSchemeGenerator; + + /** + * Register a custom tiling scheme generator + */ + static register( + generatorName: string, + generator: TilingSchemeGeneratorFunction + ) { + this._generators.set(generatorName, generator); + } + + /** + * Get a custom tiling scheme generator by its registered name + */ + static get(generatorName: string): TilingSchemeGeneratorFunction | undefined { + return this._generators.get(generatorName); + } + + /** + * Return the CRS of the tiling scheme if it is known + */ + static getCustomCrs(tilingScheme: TerriaTilingScheme): string | undefined { + return tilingScheme?.customCrs; + } + + /** + * Returns true if the tiling scheme is custom + */ + static isCustomTilingScheme(tilingScheme: TilingScheme): boolean { + return !( + tilingScheme instanceof WebMercatorTilingScheme || + tilingScheme instanceof GeographicTilingScheme + ); + } +} diff --git a/lib/Map/Leaflet/ImageryProviderLeafletTileLayer.ts b/lib/Map/Leaflet/ImageryProviderLeafletTileLayer.ts index 8d1dea7c2dd..2786a471af2 100644 --- a/lib/Map/Leaflet/ImageryProviderLeafletTileLayer.ts +++ b/lib/Map/Leaflet/ImageryProviderLeafletTileLayer.ts @@ -1,26 +1,27 @@ import i18next from "i18next"; import L, { TileEvent } from "leaflet"; import { + IReactionDisposer, autorun, computed, - IReactionDisposer, - observable, - makeObservable + makeObservable, + observable } from "mobx"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import CesiumCredit from "terriajs-cesium/Source/Core/Credit"; -import defined from "terriajs-cesium/Source/Core/defined"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import CesiumMath from "terriajs-cesium/Source/Core/Math"; import TileProviderError from "terriajs-cesium/Source/Core/TileProviderError"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; +import defined from "terriajs-cesium/Source/Core/defined"; import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo"; import ImageryProvider from "terriajs-cesium/Source/Scene/ImageryProvider"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; -import isDefined from "../../Core/isDefined"; import TerriaError from "../../Core/TerriaError"; +import isDefined from "../../Core/isDefined"; import Leaflet from "../../Models/Leaflet"; +import TilingSchemeGenerator from "../ImageryProvider/TilingSchemeGenerator"; import getUrlForImageryTile from "../ImageryProvider/getUrlForImageryTile"; import { ProviderCoords } from "../PickedFeatures/PickedFeatures"; @@ -243,28 +244,37 @@ export default class ImageryProviderLeafletTileLayer extends L.TileLayer { } const tilingScheme = this.imageryProvider.tilingScheme; - if (!(tilingScheme instanceof WebMercatorTilingScheme)) { - this.errorEvent.raiseEvent( - this, - i18next.t("map.cesium.notWebMercatorTilingScheme") - ); - return; - } + const isCustomTilingScheme = + TilingSchemeGenerator.isCustomTilingScheme(tilingScheme); - if ( - tilingScheme.getNumberOfXTilesAtLevel(0) === 2 && - tilingScheme.getNumberOfYTilesAtLevel(0) === 2 - ) { - this._zSubtract = 1; - } else if ( - tilingScheme.getNumberOfXTilesAtLevel(0) !== 1 || - tilingScheme.getNumberOfYTilesAtLevel(0) !== 1 - ) { - this.errorEvent.raiseEvent( - this, - i18next.t("map.cesium.unusalTilingScheme") - ); - return; + if (!isCustomTilingScheme) { + // These checks apply only for Geographic or WebMercator tiling schemes + + if (!(tilingScheme instanceof WebMercatorTilingScheme)) { + this.errorEvent.raiseEvent( + this, + i18next.t("map.cesium.notWebMercatorTilingScheme") + ); + return; + } + + if ( + tilingScheme.getNumberOfXTilesAtLevel(0) === 2 && + tilingScheme.getNumberOfYTilesAtLevel(0) === 2 + ) { + // This min zoom level adjustment is for Bing maps which has two + // tiles at the root level instead of a single tile. + this._zSubtract = 1; + } else if ( + tilingScheme.getNumberOfXTilesAtLevel(0) !== 1 || + tilingScheme.getNumberOfYTilesAtLevel(0) !== 1 + ) { + this.errorEvent.raiseEvent( + this, + i18next.t("map.cesium.unusalTilingScheme") + ); + return; + } } if (isDefined(this.imageryProvider.maximumLevel)) { @@ -282,7 +292,6 @@ export default class ImageryProviderLeafletTileLayer extends L.TileLayer { } this._usable = true; - this._update(); }, this._leafletUpdateInterval) as any; } diff --git a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts index d832f7cb28b..50895d9360e 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts @@ -7,18 +7,19 @@ // 4. All code for all catalog item types needs to be loaded before we can do anything. import i18next from "i18next"; import { computed, makeObservable, override, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; import GeographicTilingScheme from "terriajs-cesium/Source/Core/GeographicTilingScheme"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; -import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; +import TilingScheme from "terriajs-cesium/Source/Core/TilingScheme"; import combine from "terriajs-cesium/Source/Core/combine"; import GetFeatureInfoFormat from "terriajs-cesium/Source/Scene/GetFeatureInfoFormat"; import WebMapServiceImageryProvider from "terriajs-cesium/Source/Scene/WebMapServiceImageryProvider"; import URI from "urijs"; import { JsonObject } from "../../../Core/Json"; import TerriaError from "../../../Core/TerriaError"; -import createTransformerAllowUndefined from "../../../Core/createTransformerAllowUndefined"; import filterOutUndefined from "../../../Core/filterOutUndefined"; import isDefined from "../../../Core/isDefined"; +import TilingSchemeGenerator from "../../../Map/ImageryProvider/TilingSchemeGenerator"; import CatalogMemberMixin, { getName } from "../../../ModelMixins/CatalogMemberMixin"; @@ -35,10 +36,7 @@ import { TimeSeriesFeatureInfoContext, csvFeatureInfoContext } from "../../../Table/tableFeatureInfoContext"; -import WebMapServiceCatalogItemTraits, { - SUPPORTED_CRS_3857, - SUPPORTED_CRS_4326 -} from "../../../Traits/TraitsClasses/WebMapServiceCatalogItemTraits"; +import WebMapServiceCatalogItemTraits from "../../../Traits/TraitsClasses/WebMapServiceCatalogItemTraits"; import CommonStrata from "../../Definition/CommonStrata"; import CreateModel from "../../Definition/CreateModel"; import LoadableStratum from "../../Definition/LoadableStratum"; @@ -423,16 +421,18 @@ class WebMapServiceCatalogItem return result; } - @computed - get tilingScheme() { - if (this.crs) { - if (SUPPORTED_CRS_3857.includes(this.crs)) - return new WebMercatorTilingScheme(); - if (SUPPORTED_CRS_4326.includes(this.crs)) - return new GeographicTilingScheme(); - } + private createTilingSchemeForCrs(crs: string | undefined): TilingScheme { + const generatorName = this.tilingSchemeGenerator; + return ( + (generatorName + ? TilingSchemeGenerator.get(generatorName)?.(crs) + : undefined) ?? TilingSchemeGenerator.default(crs) + ); + } - return new WebMercatorTilingScheme(); + @computed + get tilingScheme(): TilingScheme { + return this.createTilingSchemeForCrs(this.crs); } @computed @@ -610,7 +610,7 @@ class WebMapServiceCatalogItem getFeatureInfoUrl: this.getFeatureInfoUrl, tileWidth: this.tileWidth, tileHeight: this.tileHeight, - tilingScheme: this.tilingScheme, + tilingScheme: this.createTilingSchemeForCrs(crs), maximumLevel: this.getMaximumLevel(true) ?? this.maximumLevel, minimumLevel: this.minimumLevel, credit: this.attribution diff --git a/lib/Models/Cesium.ts b/lib/Models/Cesium.ts index 03d3a100a7a..9f9c6d83ae0 100644 --- a/lib/Models/Cesium.ts +++ b/lib/Models/Cesium.ts @@ -1671,6 +1671,16 @@ export default class Cesium extends GlobeOrMap { ): ImageryLayer | undefined { if (parts.imageryProvider === undefined) return undefined; + if ( + TilingSchemeGenerator.isCustomTilingScheme( + parts.imageryProvider.tilingScheme + ) + ) { + // Ignore layers using custom tiling schemes. Without testing, these may + // result in unexpected errors when rendered with Cesium. + return; + } + const layer = this._createImageryLayer( parts.imageryProvider, parts.clippingRectangle From 7c6220b3e21303069bb073db357977d314787317 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 10:45:12 +1000 Subject: [PATCH 04/31] Add method to return imagery provider for preview maps with custom CRS. --- lib/ModelMixins/MappableMixin.ts | 10 ++++++ .../Catalog/Ows/WebMapServiceCatalogItem.ts | 34 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/ModelMixins/MappableMixin.ts b/lib/ModelMixins/MappableMixin.ts index 92409a403d9..5abb1280cd0 100644 --- a/lib/ModelMixins/MappableMixin.ts +++ b/lib/ModelMixins/MappableMixin.ts @@ -32,6 +32,16 @@ export class ImageryParts { clippingRectangle: Rectangle | undefined = undefined; show: boolean = true; + /* + * An optional method that returns an ImageryProvider instance for the + * preview map which may use a CRS and tiling scheme different to the + * default imageryprovider used for the main map. + * + * This is currently only used by plugins to customize preview map behaviour. + * (eg terriajs-plugin-proj4leaflet) + */ + previewImageryProvider?: (crs: string) => ImageryProvider | undefined; + static fromAsync(options: { imageryProviderPromise: Promise; alpha?: number; diff --git a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts index 50895d9360e..59d879a0ffb 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts @@ -452,7 +452,11 @@ class WebMapServiceCatalogItem imageryProvider, alpha: this.opacity, show: this.show, - clippingRectangle: this.clipToRectangle ? this.cesiumRectangle : undefined + clippingRectangle: this.clipToRectangle + ? this.cesiumRectangle + : undefined, + previewImageryProvider: (crs: string) => + this._createImageryProviderForCrs(this.currentDiscreteTimeTag, crs) }; } @@ -528,8 +532,15 @@ class WebMapServiceCatalogItem : undefined; } - private _createImageryProvider = createTransformerAllowUndefined( - (time: string | undefined): WebMapServiceImageryProvider | undefined => { + /** + * A memoized function that creates an ImageryProvider for the given time tag + * and CRS. + */ + private _createImageryProviderForCrs = computedFn( + ( + time: string | undefined, + crsCode: string | undefined + ): WebMapServiceImageryProvider | undefined => { // Don't show anything on the map until GetCapabilities finishes loading. if (this.isLoadingMetadata) { return undefined; @@ -538,6 +549,15 @@ class WebMapServiceCatalogItem return undefined; } + // Return if the CRS is not supported by this model + // + // TODO: this check might be too strict and could break the current + // behaviour where terria might use one of the supported CRS variants or + // fallback to EPSG:3857. + if (crsCode && !this.availableCrs?.includes(crsCode)) { + return undefined; + } + console.log(`Creating new ImageryProvider for time ${time}`); // Set dimensionParameters @@ -597,8 +617,8 @@ class WebMapServiceCatalogItem // Set CRS for WMS 1.3.0 // Set SRS for WMS 1.1.1 - const crs = this.useWmsVersion130 ? this.crs : undefined; - const srs = this.useWmsVersion130 ? undefined : this.crs; + const crs = this.useWmsVersion130 ? crsCode : undefined; + const srs = this.useWmsVersion130 ? undefined : crsCode; const imageryOptions: WebMapServiceImageryProvider.ConstructorOptions = { url: proxyCatalogItemUrl(this, baseUrl.toString()), @@ -639,6 +659,10 @@ class WebMapServiceCatalogItem } ); + private _createImageryProvider(time: string | undefined) { + return this._createImageryProviderForCrs(time, this.crs); + } + @computed get styleSelectableDimensions(): SelectableDimensionEnum[] { return this.availableStyles.map((layer, layerIndex) => { From 462a84849463345ea14705f96fcd608fe628b22f Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 10:58:56 +1000 Subject: [PATCH 05/31] Create PreviewViewer class to use with preview maps. --- lib/ReactViews/Preview/DataPreviewMap.jsx | 140 ++-------------- lib/ViewModels/PreviewViewer.ts | 190 ++++++++++++++++++++++ 2 files changed, 203 insertions(+), 127 deletions(-) create mode 100644 lib/ViewModels/PreviewViewer.ts diff --git a/lib/ReactViews/Preview/DataPreviewMap.jsx b/lib/ReactViews/Preview/DataPreviewMap.jsx index cf171821e69..07c2146dbbf 100644 --- a/lib/ReactViews/Preview/DataPreviewMap.jsx +++ b/lib/ReactViews/Preview/DataPreviewMap.jsx @@ -4,52 +4,18 @@ import { action, autorun, computed, - observable, + makeObservable, runInAction, - makeObservable + trace } from "mobx"; import { observer } from "mobx-react"; import PropTypes from "prop-types"; import { Component } from "react"; import { withTranslation } from "react-i18next"; -import CesiumMath from "terriajs-cesium/Source/Core/Math"; -import filterOutUndefined from "../../Core/filterOutUndefined"; -import MappableMixin, { ImageryParts } from "../../ModelMixins/MappableMixin"; -import CommonStrata from "../../Models/Definition/CommonStrata"; -import CreateModel from "../../Models/Definition/CreateModel"; -import GeoJsonCatalogItem from "../../Models/Catalog/CatalogItems/GeoJsonCatalogItem"; import ViewerMode from "../../Models/ViewerMode"; -import MappableTraits from "../../Traits/TraitsClasses/MappableTraits"; -import TerriaViewer from "../../ViewModels/TerriaViewer"; +import PreviewViewer from "../../ViewModels/PreviewViewer"; import Styles from "./data-preview-map.scss"; -class AdaptForPreviewMap extends MappableMixin(CreateModel(MappableTraits)) { - previewed; - - constructor(...args) { - super(...args); - makeObservable(this); - } - - async forceLoadMapItems() {} - - // Make all imagery 0 or 100% opacity - @computed - get mapItems() { - return ( - this.previewed?.mapItems.map((m) => - ImageryParts.is(m) - ? { - ...m, - alpha: m.alpha !== 0.0 ? 1.0 : 0.0, - show: true - } - : m - ) ?? [] - ); - } -} - /** * Leaflet-based preview map that sits within the preview. */ @@ -69,9 +35,6 @@ class AdaptForPreviewMap extends MappableMixin(CreateModel(MappableTraits)) { */ @observer class DataPreviewMap extends Component { - @observable - isZoomedToExtent = false; - /** * @type {TerriaViewer} * @readonly @@ -115,23 +78,19 @@ class DataPreviewMap extends Component { this.initPreview(container); } }); - this.previewViewer = new TerriaViewer( + this.previewViewer = new PreviewViewer( this.props.terria, - computed(() => { - const previewItem = new AdaptForPreviewMap(); - previewItem.previewed = this.props.previewed; - // Can previewed be undefined? - return filterOutUndefined([ - previewItem, - this.boundingRectangleCatalogItem - ]); - }) + // pass in computed so that the prop changes are propogated + computed(() => this.props.previewed) ); + runInAction(() => { this.previewViewer.viewerMode = ViewerMode.Leaflet; this.previewViewer.disableInteraction = true; this.previewViewer.homeCamera = this.props.terria.mainViewer.homeCamera; }); + + window.previewViewer = this.previewViewer; // Not yet implemented // previewViewer.hideTerriaLogo = true; // previewViewer.homeView = terria.homeView; @@ -146,7 +105,7 @@ class DataPreviewMap extends Component { console.log( "Initialising preview map. This might be expensive, so this should only show up when the preview map disappears and reappears" ); - this.isZoomedToExtent = false; + this.previewViewer.isZoomedToExtent = false; const baseMapItems = this.props.terria.baseMapsModel.baseMapItems; // Find preview basemap using `terria.previewBaseMapId` const initPreviewBaseMap = baseMapItems.find( @@ -165,7 +124,7 @@ class DataPreviewMap extends Component { this.previewViewer.attach(container); this._disposeZoomToExtentSubscription = autorun(() => { - if (this.isZoomedToExtent) { + if (this.previewViewer.isZoomedToExtent) { this.previewViewer.currentViewer.zoomTo(this.props.previewed); } else { this.previewViewer.currentViewer.zoomTo(this.previewViewer.homeCamera); @@ -185,86 +144,13 @@ class DataPreviewMap extends Component { } } - @computed - get boundingRectangleCatalogItem() { - const rectangle = this.props.previewed.rectangle; - if (rectangle === undefined) { - return undefined; - } - - let west = rectangle.west; - let south = rectangle.south; - let east = rectangle.east; - let north = rectangle.north; - - if ( - west === undefined || - south === undefined || - east === undefined || - north === undefined - ) { - return undefined; - } - - if (!this.isZoomedToExtent) { - // When zoomed out, make sure the dataset rectangle is at least 5% of the width and height - // the home view, so that it is actually visible. - const minimumFraction = 0.05; - const homeView = this.previewViewer.homeCamera; - const minimumWidth = - CesiumMath.toDegrees(homeView.rectangle.width) * minimumFraction; - if (east - west < minimumWidth) { - const center = (east + west) * 0.5; - west = center - minimumWidth * 0.5; - east = center + minimumWidth * 0.5; - } - - const minimumHeight = - CesiumMath.toDegrees(homeView.rectangle.height) * minimumFraction; - if (north - south < minimumHeight) { - const center = (north + south) * 0.5; - south = center - minimumHeight * 0.5; - north = center + minimumHeight * 0.5; - } - } - - const rectangleCatalogItem = new GeoJsonCatalogItem( - "__preview-data-extent", - this.props.terria - ); - rectangleCatalogItem.setTrait(CommonStrata.user, "geoJsonData", { - type: "FeatureCollection", - features: [ - { - type: "Feature", - properties: { - stroke: "#08ABD5", - "stroke-width": 2, - "stroke-opacity": 1 - }, - geometry: { - type: "LineString", - coordinates: [ - [west, south], - [west, north], - [east, north], - [east, south], - [west, south] - ] - } - } - ] - }); - rectangleCatalogItem.loadMapItems(); - return rectangleCatalogItem; - } - @action.bound clickMap(_evt) { - this.isZoomedToExtent = !this.isZoomedToExtent; + this.previewViewer.isZoomedToExtent = !this.previewViewer.isZoomedToExtent; } render() { + trace(); const { t } = this.props; const previewBadgeLabels = { loading: t("preview.loading"), diff --git a/lib/ViewModels/PreviewViewer.ts b/lib/ViewModels/PreviewViewer.ts new file mode 100644 index 00000000000..61618b9c93b --- /dev/null +++ b/lib/ViewModels/PreviewViewer.ts @@ -0,0 +1,190 @@ +import { IComputedValue, computed, makeObservable, observable } from "mobx"; +import CesiumMath from "terriajs-cesium/Source/Core/Math"; +import filterOutUndefined from "../Core/filterOutUndefined"; +import MappableMixin, { ImageryParts } from "../ModelMixins/MappableMixin"; +import GeoJsonCatalogItem from "../Models/Catalog/CatalogItems/GeoJsonCatalogItem"; +import CommonStrata from "../Models/Definition/CommonStrata"; +import CreateModel from "../Models/Definition/CreateModel"; +import hasTraits from "../Models/Definition/hasTraits"; +import Terria from "../Models/Terria"; +import CrsTraits from "../Traits/TraitsClasses/CrsTraits"; +import MappableTraits from "../Traits/TraitsClasses/MappableTraits"; +import TerriaViewer from "./TerriaViewer"; + +/** + * Viewer instance used with DataPreivewMap + */ +export default class PreviewViewer extends TerriaViewer { + /** + * True if the preview map is currently zoomed to the items extent, otherwise + * it is zoomed to the home camera view. + */ + @observable isZoomedToExtent = false; + + /** + * @param terria Terria instance + * @param previewed A computed value that returns the previewed item + */ + constructor( + terria: Terria, + previewed: IComputedValue + ) { + super( + terria, + computed(() => { + // Wrap preview item in an adapter that boosts the opacity value for imagery items + const previewedItem = previewed.get(); + const previewAdapter = new AdaptForPreviewMap(previewedItem); + // Show rectangle extent of the previewed item + return filterOutUndefined([ + previewAdapter, + this.boundingRectangleCatalogItem(previewedItem) + ]); + }) + ); + makeObservable(this); + } + + @computed + get previewed(): MappableMixin.Instance | undefined { + const item = this.items.get()[0]; + return item instanceof AdaptForPreviewMap ? item.previewed : undefined; + } + + private boundingRectangleCatalogItem( + previewed: MappableMixin.Instance + ): GeoJsonCatalogItem | undefined { + let rectangle, crs; + + if (hasTraits(previewed, CrsTraits, "previewCrs", "boundingBoxes")) { + // Use native bounding box for the model if we know it. + // + // To define an extent we generate 4 points. We can generate it from 2 + // corner points given in degress (west, south, east, north) by the + // model's rectangle definition. However for projections that cross the + // pole 2 opposite corners of a rectangle can have the same latitude and + // would look like a line if generated from just two points. In this case + // we need either 2 corner points in native CRS or 4 points in + // WGS84. Here we define the extent using 2 corner points in native CRS. + const crsCode = previewed.previewCrs; + const bbox = previewed.boundingBoxes.find((b) => b.crs === crsCode); + if (bbox && crsCode) { + // coords in this will be in CRS native coordinate space + rectangle = { + west: bbox.min.x, + south: bbox.min.y, + east: bbox.max.x, + north: bbox.max.y + }; + + crs = { + type: "name", + properties: { + name: crsCode + } + }; + } + } + + rectangle ??= previewed.rectangle; + let { west, south, east, north } = rectangle ?? {}; + if ( + west === undefined || + south === undefined || + east === undefined || + north === undefined + ) { + return undefined; + } + + if (!this.isZoomedToExtent && !crs) { + // When zoomed out, make sure the dataset rectangle is at least 5% of the width and height + // the home view, so that it is actually visible. + const minimumFraction = 0.05; + const homeView = this.homeCamera; + const minimumWidth = + CesiumMath.toDegrees(homeView.rectangle.width) * minimumFraction; + if (east - west < minimumWidth) { + const center = (east + west) * 0.5; + west = center - minimumWidth * 0.5; + east = center + minimumWidth * 0.5; + } + + const minimumHeight = + CesiumMath.toDegrees(homeView.rectangle.height) * minimumFraction; + if (north - south < minimumHeight) { + const center = (north + south) * 0.5; + south = center - minimumHeight * 0.5; + north = center + minimumHeight * 0.5; + } + } + + const rectangleCatalogItem = new GeoJsonCatalogItem( + "__preview-data-extent", + this.terria + ); + + const geoJsonData = { + crs, + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: { + stroke: "#08ABD5", + "stroke-width": 2, + "stroke-opacity": 1 + }, + geometry: { + type: "LineString", + coordinates: [ + [west, south], + [west, north], + [east, north], + [east, south], + [west, south] + ] + } + } + ] + }; + + rectangleCatalogItem.setTrait( + CommonStrata.user, + "geoJsonData", + geoJsonData + ); + + rectangleCatalogItem.loadMapItems(); + return rectangleCatalogItem; + } +} + +class AdaptForPreviewMap extends MappableMixin(CreateModel(MappableTraits)) { + previewed: MappableMixin.Instance; + + constructor(previewed: MappableMixin.Instance) { + super(undefined, previewed.terria); + makeObservable(this); + + this.previewed = previewed; + } + + async forceLoadMapItems() {} + + // Make all imagery 0 or 100% opacity + @computed + get mapItems() { + return ( + this.previewed?.mapItems.map((m) => + ImageryParts.is(m) + ? { + ...m, + alpha: m.alpha !== 0.0 ? 1.0 : 0.0, + show: true + } + : m + ) ?? [] + ); + } +} From 2e1a137456f13571293cc83dd440e60630c48328 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 11:32:09 +1000 Subject: [PATCH 06/31] Track initial camera view for viewers and return it if the camera hasn't changed. Previously, the camera view would drift when switching between viewer modes. This is because each time we switch the viewer, we get the current view from leaflet/cesium scene which renders the map differently. This can be a problem when restoring share links and the viewer is first initialized in 3d/cesium mode and then switches to leaflet/2d mode. This change makes sure that the view remains stable if the camera hasn't changed after setting the initial view. --- lib/Models/Cesium.ts | 23 +++++++++++++++++++++++ lib/Models/GlobeOrMap.ts | 5 +++++ lib/Models/Leaflet.ts | 27 ++++++++++++++++++++++++++- lib/Models/NoViewer.ts | 4 ++++ lib/ViewModels/TerriaViewer.ts | 2 +- 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/Models/Cesium.ts b/lib/Models/Cesium.ts index 9f9c6d83ae0..251bcb212aa 100644 --- a/lib/Models/Cesium.ts +++ b/lib/Models/Cesium.ts @@ -93,6 +93,7 @@ import GlobeOrMap from "./GlobeOrMap"; import Terria from "./Terria"; import UserDrawing from "./UserDrawing"; import { setViewerMode } from "./ViewerMode"; +import TilingSchemeGenerator from "../Map/ImageryProvider/TilingSchemeGenerator"; //import Cesium3DTilesInspector from "terriajs-cesium/Source/Widgets/Cesium3DTilesInspector/Cesium3DTilesInspector"; @@ -170,6 +171,12 @@ export default class Cesium extends GlobeOrMap { }); private _terrainMessageViewed: boolean = false; + + /** + * Save last view set using setView + */ + private _initialView: CameraView | undefined; + constructor(terriaViewer: TerriaViewer, container: string | HTMLElement) { super(); @@ -958,6 +965,15 @@ export default class Cesium extends GlobeOrMap { return _zoom().finally(() => this.notifyRepaintRequired()); } + setInitialView(view: CameraView) { + this.doZoomTo(view, 0); + this._initialView = view; + const removeListener = this.scene.camera.changed.addEventListener(() => { + this._initialView = undefined; + removeListener(); + }); + } + notifyRepaintRequired(): void { this.pauser.notifyRepaintRequired(); } @@ -1016,6 +1032,13 @@ export default class Cesium extends GlobeOrMap { } getCurrentCameraView(): CameraView { + // Return the initial view if the camera hasn't changed since setting it. + // This ensures that the view remains constant when switching between + // viewer modes. + if (this._initialView) { + return this._initialView; + } + const scene = this.scene; const camera = scene.camera; diff --git a/lib/Models/GlobeOrMap.ts b/lib/Models/GlobeOrMap.ts index 0b27f653871..b84eaeb7817 100644 --- a/lib/Models/GlobeOrMap.ts +++ b/lib/Models/GlobeOrMap.ts @@ -103,6 +103,11 @@ export default abstract class GlobeOrMap { ); } + /** + * Set initial camera view + */ + abstract setInitialView(cameraView: CameraView): void; + abstract getCurrentCameraView(): CameraView; /* Gets the current container element. diff --git a/lib/Models/Leaflet.ts b/lib/Models/Leaflet.ts index b145b592837..9a8c264abe0 100644 --- a/lib/Models/Leaflet.ts +++ b/lib/Models/Leaflet.ts @@ -91,6 +91,8 @@ export default class Leaflet extends GlobeOrMap { @observable nw: L.Point | undefined; @observable se: L.Point | undefined; + private _initialView: CameraView | undefined; + @action private updateMapObservables() { this.size = this.map.getSize(); @@ -340,6 +342,7 @@ export default class Leaflet extends GlobeOrMap { this.dataSourceDisplay.destroy(); this.map.off("move"); this.map.off("zoom"); + this.map.off("zoomlevelschange"); this.map.remove(); } @@ -502,7 +505,6 @@ export default class Leaflet extends GlobeOrMap { return Promise.resolve(); } let bounds; - if (isDefined(target.entities)) { if (isDefined(this.dataSourceDisplay)) { bounds = this.dataSourceDisplay.getLatLngBounds(target); @@ -550,7 +552,30 @@ export default class Leaflet extends GlobeOrMap { return Promise.resolve(); } + setInitialView(view: CameraView) { + this.doZoomTo(view, 0); + this._initialView = view; + this.map.addOneTimeEventListener("move", () => { + this._initialView = undefined; + }); + } + + /** + * Return the initial view if it hasn't changed. Otherwise return undefined. + */ + getInitialView(): CameraView | undefined { + return this._initialView; + } + getCurrentCameraView(): CameraView { + // Return the initial view if the camera hasn't changed since setting it. + // This ensures that the view remains constant when switching between + // viewer modes. + const initialView = this.getInitialView(); + if (initialView) { + return initialView; + } + const bounds = this.map.getBounds(); return new CameraView( Rectangle.fromDegrees( diff --git a/lib/Models/NoViewer.ts b/lib/Models/NoViewer.ts index d420e4ac4df..50c2fbc353e 100644 --- a/lib/Models/NoViewer.ts +++ b/lib/Models/NoViewer.ts @@ -36,6 +36,10 @@ class NoViewer extends GlobeOrMap { return Promise.resolve(); } + setInitialView(view: CameraView) { + this._currentView = view; + } + notifyRepaintRequired(): void {} pickFromLocation( diff --git a/lib/ViewModels/TerriaViewer.ts b/lib/ViewModels/TerriaViewer.ts index 99c5b5c0a2c..e2e4f39eaeb 100644 --- a/lib/ViewModels/TerriaViewer.ts +++ b/lib/ViewModels/TerriaViewer.ts @@ -220,7 +220,7 @@ export default class TerriaViewer { } this._lastViewer = newViewer; - newViewer.zoomTo(currentView || untracked(() => this.homeCamera), 0.0); + newViewer.setInitialView(currentView || untracked(() => this.homeCamera)); return newViewer; } From 91265c882b4a55567d348eeb2bb4cb4d673d608d Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:10:04 +1000 Subject: [PATCH 07/31] Create camera view from polygon extent. This is useful for accurately describing extent that crosse the poles where we need at least 4 lat, lon points. --- lib/Models/CameraView.ts | 68 +++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/lib/Models/CameraView.ts b/lib/Models/CameraView.ts index 65104f4d4ce..a0eefd0bb5e 100644 --- a/lib/Models/CameraView.ts +++ b/lib/Models/CameraView.ts @@ -16,6 +16,7 @@ import JsonValue, { JsonObject } from "../Core/Json"; import TerriaError from "../Core/TerriaError"; +import filterOutUndefined from "../Core/filterOutUndefined"; /** * Holds a camera's view parameters, expressed as a rectangular extent and/or as a camera position, direction, @@ -30,6 +31,13 @@ export default class CameraView { */ readonly rectangle: Readonly; + /** + * Points describing a bounding box. This is for example useful to describe + * extent that crosses the poles. We need at least 4 lat lon points to + * accurately describe them. + */ + readonly extent: Readonly | undefined; + /** * Gets the position of the camera in the Earth-centered Fixed frame. */ @@ -49,7 +57,8 @@ export default class CameraView { rectangle: Rectangle, position?: Cartesian3, direction?: Cartesian3, - up?: Cartesian3 + up?: Cartesian3, + extent?: Cartographic[] ) { this.rectangle = Rectangle.clone(rectangle); @@ -68,6 +77,7 @@ export default class CameraView { this.direction = Cartesian3.clone(direction); this.up = Cartesian3.clone(up); } + this.extent = extent; } toJson(): JsonObject { @@ -92,6 +102,14 @@ export default class CameraView { result.up = vectorToJson(this.up); } + if (this.extent) { + // TODO: if extent is given, derive south,west,east,north from extent? + result.extent = this.extent.map((c) => ({ + latitude: CesiumMath.toDegrees(c.latitude), + longitude: CesiumMath.toDegrees(c.longitude) + })); + } + return result; } @@ -192,20 +210,39 @@ export default class CameraView { json.north ); + let extent: Cartographic[] | undefined; + if (Array.isArray(json.extent)) { + extent = filterOutUndefined( + json.extent.map((ll) => + isJsonObject(ll) && + typeof ll.longitude === "number" && + typeof ll.latitude === "number" + ? Cartographic.fromDegrees(ll.longitude, ll.latitude) + : undefined + ) + ); + } + + let position, direction, up; if ( isVector(json.position) && isVector(json.direction) && isVector(json.up) ) { - return new CameraView( - rectangle, - new Cartesian3(json.position.x, json.position.y, json.position.z), - new Cartesian3(json.direction.x, json.direction.y, json.direction.z), - new Cartesian3(json.up.x, json.up.y, json.up.z) + position = new Cartesian3( + json.position.x, + json.position.y, + json.position.z ); - } else { - return new CameraView(rectangle); + direction = new Cartesian3( + json.direction.x, + json.direction.y, + json.direction.z + ); + up = new Cartesian3(json.up.x, json.up.y, json.up.z); } + + return new CameraView(rectangle, position, direction, up, extent); } } @@ -348,6 +385,21 @@ export default class CameraView { return new CameraView(extent, positionECF, directionECF, upECF); } + + /** + * Create a camera view from polygon extent + */ + static fromExtent(extent: Cartographic[]) { + const positions = extent.map((c) => Cartographic.toCartesian(c)); + const cameraView = new CameraView( + Rectangle.fromCartesianArray(positions), + undefined, + undefined, + undefined, + extent + ); + return cameraView; + } } function isVector( From 69a112a7205c3cb8de9c133ad0935e4e207d23d5 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:11:44 +1000 Subject: [PATCH 08/31] Make Leaflet accept initial options parameter. --- lib/Models/Leaflet.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/Models/Leaflet.ts b/lib/Models/Leaflet.ts index 9a8c264abe0..3839e38f94e 100644 --- a/lib/Models/Leaflet.ts +++ b/lib/Models/Leaflet.ts @@ -143,7 +143,11 @@ export default class Leaflet extends GlobeOrMap { ); } - constructor(terriaViewer: TerriaViewer, container: string | HTMLElement) { + constructor( + terriaViewer: TerriaViewer, + container: string | HTMLElement, + initOptions?: { mapOptions?: L.MapOptions } + ) { super(); makeObservable(this); this.terria = terriaViewer.terria; @@ -153,8 +157,11 @@ export default class Leaflet extends GlobeOrMap { attributionControl: false, zoomSnap: 1, // Change to 0.2 for incremental zoom when Chrome fixes canvas scaling gaps preferCanvas: true, - worldCopyJump: false - }).setView([-28.5, 135], 5); + worldCopyJump: false, + center: [-28.5, 135], + zoom: 5, + ...initOptions?.mapOptions + }); this.map.on("move", () => this.updateMapObservables()); this.map.on("zoom", () => this.updateMapObservables()); From 692473f3aa003baa4ca455150cfbca033eb3516c Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:12:22 +1000 Subject: [PATCH 09/31] Make _makeImageryLayerFromParts protected. To allow child classes to override it. --- lib/Models/Leaflet.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Models/Leaflet.ts b/lib/Models/Leaflet.ts index 3839e38f94e..11f6cdddc35 100644 --- a/lib/Models/Leaflet.ts +++ b/lib/Models/Leaflet.ts @@ -119,7 +119,13 @@ export default class Leaflet extends GlobeOrMap { } }); - private _makeImageryLayerFromParts( + /** + * Make leaflet layer for ImageryParts + * + * Alternate implementations my override this method, so it is marked as + * `protected` method (eg terriajs-plugin-proj4leaflet) + */ + protected _makeImageryLayerFromParts( parts: ImageryParts, item: MappableMixin.Instance ) { From 138708be7b1a66243a48cb289a5edfa1bd20d4df Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:24:56 +1000 Subject: [PATCH 10/31] Add preferredViewerMode for basemaps and track loading basemap. --- lib/Models/ViewerMode.ts | 14 +++++ lib/Traits/TraitsClasses/MappableTraits.ts | 8 +++ lib/ViewModels/TerriaViewer.ts | 67 +++++++++++++++++----- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/lib/Models/ViewerMode.ts b/lib/Models/ViewerMode.ts index 52533c9af34..d8cf74e4d2c 100644 --- a/lib/Models/ViewerMode.ts +++ b/lib/Models/ViewerMode.ts @@ -48,4 +48,18 @@ export function setViewerMode( }); } +/** + * Returns the viewer type for the given viewer mode + */ +export function getViewerType(viewerMode: string): ViewerMode | undefined { + // Note: + // There is small naming ambiguity here + // ViewerMode can either mean Leaflet|Cesium|NoViewer or 3d|3dsmooth|2d + // The 3d|2d sense of viewermode is used in APIs, for eg as a localStorage + // So I think we should renmae Leaflet|Cesium... to viewerType + if (isViewerMode(viewerMode)) { + return MapViewers[viewerMode].viewerMode; + } +} + export default ViewerMode; diff --git a/lib/Traits/TraitsClasses/MappableTraits.ts b/lib/Traits/TraitsClasses/MappableTraits.ts index 1316c3e6b09..64da45913d3 100644 --- a/lib/Traits/TraitsClasses/MappableTraits.ts +++ b/lib/Traits/TraitsClasses/MappableTraits.ts @@ -281,6 +281,14 @@ class MappableTraits extends mixTraits(AttributionTraits) { 'The maximum number of "feature infos" that can be displayed in feature info panel.' }) maximumShownFeatureInfos?: number; + + @primitiveTrait({ + type: "string", + name: "Preferred viewer mode", + description: + "The preferred viewer mode for this item - either '2d' '3d' or '3dsmooth'. If this dataset is used as a basemap then we automatically switch the viewer to the preferred mode. However the user can still switch to another mode, so this preference is not strongly enforced." + }) + preferredViewerMode?: string; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ diff --git a/lib/ViewModels/TerriaViewer.ts b/lib/ViewModels/TerriaViewer.ts index e2e4f39eaeb..eae858a8435 100644 --- a/lib/ViewModels/TerriaViewer.ts +++ b/lib/ViewModels/TerriaViewer.ts @@ -55,28 +55,67 @@ export default class TerriaViewer { @observable private _baseMap: MappableMixin.Instance | undefined; + /** + * Tracks the basemap that is currently being loaded + */ + @observable + private _loadingBaseMap: MappableMixin.Instance | undefined; + get baseMap() { return this._baseMap; } + /** + * Returns the basemap that is currently loading + */ + get loadingBaseMap(): MappableMixin.Instance | undefined { + return this._loadingBaseMap; + } + async setBaseMap(baseMap?: MappableMixin.Instance): Promise { if (!baseMap) return; - const result = await baseMap.loadMapItems(); - if (result.error) { - result.raiseError(this.terria, { - title: { - key: "models.terria.loadingBaseMapErrorTitle", - parameters: { - name: - (CatalogMemberMixin.isMixedInto(baseMap) - ? baseMap.name - : baseMap.uniqueId) ?? "Unknown item" + runInAction(() => { + this._loadingBaseMap = baseMap; + }); + + try { + const result = await baseMap.loadMapItems(); + + if (result.error) { + result.raiseError(this.terria, { + title: { + key: "models.terria.loadingBaseMapErrorTitle", + parameters: { + name: + (CatalogMemberMixin.isMixedInto(baseMap) + ? baseMap.name + : baseMap.uniqueId) ?? "Unknown item" + } } - } - }); - } else { - runInAction(() => (this._baseMap = baseMap)); + }); + } else { + runInAction(() => { + // Concurrent attempts to load basemap might not complete in the same + // order they were called. Set as current basemap only if this was + // the last call to setBaseMap. + if (this._loadingBaseMap === baseMap) { + // If the basemap specifies a preferred viewer mode, switch to it. + if (baseMap.preferredViewerMode) { + this.viewerMode = + getViewerType(baseMap.preferredViewerMode) ?? this.viewerMode; + } + this._baseMap = baseMap; + } + }); + } + } finally { + // Unset loadingBaseMap + if (this._loadingBaseMap === baseMap) { + runInAction(() => { + this._loadingBaseMap = undefined; + }); + } } } From a013ab918a2cff788afbdbd964b569164b2edf02 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:28:44 +1000 Subject: [PATCH 11/31] Load persisted basemap only when the initsource basemap data fails to load. --- lib/Models/Terria.ts | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/lib/Models/Terria.ts b/lib/Models/Terria.ts index 7093f21b00b..8cf4e8f5f00 100644 --- a/lib/Models/Terria.ts +++ b/lib/Models/Terria.ts @@ -634,6 +634,10 @@ export default class Terria { */ private focusWorkbenchItemsAfterLoadingInitSources: boolean = false; + private _loadPersistedSettings: { baseMapPromise?: Promise } = { + baseMapPromise: undefined + }; + @computed get baseMapContrastColor() { return ( @@ -1437,12 +1441,17 @@ export default class Terria { } } - // Load basemap - runInAction(() => { - if (!this.mainViewer.baseMap) { - // Note: there is no "await" here - as basemaps can take a while to load and there is no need to wait for them to load before rendering Terria - this.loadPersistedOrInitBaseMap(); - } + // Load basemap. Wait for any basemap loaded from applyInitData to finish + // loading before we restore from user preference. + Promise.resolve(this._loadPersistedSettings.baseMapPromise).finally(() => { + runInAction(() => { + if (!this.mainViewer.baseMap) { + // Note: there is no "await" here - as basemaps can take a while + // to load and there is no need to wait for them to load before + // rendering Terria + this.loadPersistedOrInitBaseMap(); + } + }); }); // Zoom to workbench items if any of the init sources specifically requested it @@ -1735,7 +1744,9 @@ export default class Terria { // Add map settings if (isJsonString(initData.viewerMode)) { const viewerMode = initData.viewerMode.toLowerCase(); - if (isViewerMode(viewerMode)) setViewerMode(viewerMode, this.mainViewer); + if (isViewerMode(viewerMode)) { + setViewerMode(viewerMode, this.mainViewer); + } } if (isJsonObject(initData.baseMaps)) { @@ -1793,11 +1804,15 @@ export default class Terria { ); } if (isJsonString(initData.settings.baseMapId)) { - this.mainViewer.setBaseMap( - this.baseMapsModel.baseMapItems.find( - (item) => item.item.uniqueId === initData.settings!.baseMapId - )?.item - ); + this._loadPersistedSettings.baseMapPromise = this.mainViewer + .setBaseMap( + this.baseMapsModel.baseMapItems.find( + (item) => item.item.uniqueId === initData.settings!.baseMapId + )?.item + ) + .finally(() => { + this._loadPersistedSettings.baseMapPromise = undefined; + }); } if (isJsonNumber(initData.settings.terrainSplitDirection)) { this.terrainSplitDirection = initData.settings.terrainSplitDirection; From 75d0cab2229e63f5047dc70000535efbe6d110cf Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:32:45 +1000 Subject: [PATCH 12/31] Make Leaflet, Cesium loaders overridable. Eg, plugins may provide custom version of Cesium or Leaflet viewers. --- lib/ViewModels/TerriaViewer.ts | 52 ++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/lib/ViewModels/TerriaViewer.ts b/lib/ViewModels/TerriaViewer.ts index eae858a8435..37614178d5e 100644 --- a/lib/ViewModels/TerriaViewer.ts +++ b/lib/ViewModels/TerriaViewer.ts @@ -1,17 +1,17 @@ import { isEqual } from "lodash-es"; import { - action, - computed, IComputedValue, IObservableValue, IReactionDisposer, + action, + computed, + makeObservable, observable, reaction, runInAction, - untracked, - makeObservable + untracked } from "mobx"; -import { fromPromise, FULFILLED, IPromiseBasedObservable } from "mobx-utils"; +import { FULFILLED, IPromiseBasedObservable, fromPromise } from "mobx-utils"; import CesiumEvent from "terriajs-cesium/Source/Core/Event"; import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import CatalogMemberMixin from "../ModelMixins/CatalogMemberMixin"; @@ -20,21 +20,7 @@ import CameraView from "../Models/CameraView"; import GlobeOrMap from "../Models/GlobeOrMap"; import NoViewer from "../Models/NoViewer"; import Terria from "../Models/Terria"; -import ViewerMode from "../Models/ViewerMode"; - -// Async loading of Leaflet and Cesium - -const leafletFromPromise = computed( - () => - fromPromise(import("../Models/Leaflet").then((Leaflet) => Leaflet.default)), - { keepAlive: true } -); - -const cesiumFromPromise = computed( - () => - fromPromise(import("../Models/Cesium").then((Cesium) => Cesium.default)), - { keepAlive: true } -); +import ViewerMode, { getViewerType } from "../Models/ViewerMode"; // Viewer options. Designed to be easily serialisable interface ViewerOptions { @@ -50,6 +36,18 @@ const viewerOptionsDefaults: ViewerOptions = { * Each map-view should have it's own TerriaViewer (main viewer, preview map, etc.) */ export default class TerriaViewer { + /** + * Loaders for different viewers. + * + * Plugins may override the loaders to customize the viewer implementation. + */ + static readonly Loaders = observable({ + [ViewerMode.Cesium]: (_terriaViewer: TerriaViewer) => + import("../Models/Cesium").then((mod) => mod.default), + [ViewerMode.Leaflet]: (_terriaViewer: TerriaViewer) => + import("../Models/Leaflet").then((mod) => mod.default) + }); + readonly terria: Terria; @observable @@ -209,13 +207,23 @@ export default class TerriaViewer { typeof NoViewer >; if (this.attached && this.viewerMode === ViewerMode.Leaflet) { - viewerFromPromise = leafletFromPromise.get(); + viewerFromPromise = this.leafletPromise; } else if (this.attached && this.viewerMode === ViewerMode.Cesium) { - viewerFromPromise = cesiumFromPromise.get(); + viewerFromPromise = this.cesiumPromise; } return viewerFromPromise; } + @computed({ keepAlive: true }) + private get leafletPromise() { + return fromPromise(TerriaViewer.Loaders[ViewerMode.Leaflet](this)); + } + + @computed({ keepAlive: true }) + private get cesiumPromise() { + return fromPromise(TerriaViewer.Loaders[ViewerMode.Cesium](this)); + } + @computed({ keepAlive: true }) From 6bc80f9a905a600649326af8e83ca96083592d0c Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:42:47 +1000 Subject: [PATCH 13/31] Add hook to inject custom UI into map settings panel. --- lib/ReactViewModels/ViewState.ts | 9 +++++++++ lib/ReactViews/Map/Panels/SettingPanel.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/ReactViewModels/ViewState.ts b/lib/ReactViewModels/ViewState.ts index 11e97f1a133..9b6a0ddc5b6 100644 --- a/lib/ReactViewModels/ViewState.ts +++ b/lib/ReactViewModels/ViewState.ts @@ -157,6 +157,15 @@ export default class ViewState { readonly featureInfoPanelButtonGenerators: FeatureInfoPanelButtonGenerator[] = []; + /** + * @private + * + * Hook used by proj4leaflet plugin to extend the SettingsPanel UI. + * Do not rely on this; marked as private as it can change. + */ + @observable + _customMapViewOptions?: ComponentType<{}>; + @action setSelectedTrainerItem(trainerItem: string): void { this.selectedTrainerItem = trainerItem; diff --git a/lib/ReactViews/Map/Panels/SettingPanel.tsx b/lib/ReactViews/Map/Panels/SettingPanel.tsx index c7b73dcf90c..bf94b2d88e2 100644 --- a/lib/ReactViews/Map/Panels/SettingPanel.tsx +++ b/lib/ReactViews/Map/Panels/SettingPanel.tsx @@ -4,6 +4,7 @@ import Slider from "rc-slider"; import { ChangeEvent, ComponentProps, + ComponentType, FC, MouseEvent, Ref, @@ -29,6 +30,7 @@ import { useViewState } from "../../Context"; import { useRefForTerria } from "../../Hooks/useRefForTerria"; import MenuPanel from "../../StandardUserInterface/customizable/MenuPanel"; import Styles from "./setting-panel.scss"; +import RaiseToUserErrorBoundary from "../../Errors/RaiseToUserErrorBoundary"; const sides = { left: "settingPanel.terrain.left", @@ -203,6 +205,8 @@ const SettingPanel: FC = observer(() => { ? t("settingPanel.timeline.alwaysShowLabel") : t("settingPanel.timeline.hideLabel"); + const CustomMapViewOptions = viewState._customMapViewOptions; + return ( //@ts-expect-error - not yet ready to tackle tsfying MenuPanel { ))} + {CustomMapViewOptions && ( + + + + )} {!!supportsSide && ( <> From 321f88da94175c76bb971aeb79b039adb52d62ed Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 12:43:10 +1000 Subject: [PATCH 14/31] Upgrade @types/leaflet. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b916f7d7bb6..61e17587cc0 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@types/geojson-vt": "^3.2.1", "@types/jasmine": "^2.8.8", "@types/jasmine-ajax": "^3.3.0", - "@types/leaflet": "^1.9.12", + "@types/leaflet": "^1.9.17", "@types/linkify-it": "^3.0.5", "@types/lodash-es": "^4.17.3", "@types/markdown-it": "^14.0.1", From b73b3605a8602fcc03b2de98fd381e3124a09c1a Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 13:12:28 +1000 Subject: [PATCH 15/31] Temporarily use terriamap#proj4leaflet for CI. --- buildprocess/ci-deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildprocess/ci-deploy.sh b/buildprocess/ci-deploy.sh index a07652ca158..42ee073dbb0 100644 --- a/buildprocess/ci-deploy.sh +++ b/buildprocess/ci-deploy.sh @@ -22,7 +22,7 @@ npm install -g yarn@^1.19.0 # Clone and build TerriaMap, using this version of TerriaJS TERRIAJS_COMMIT_HASH=$(git rev-parse HEAD) -git clone -b main https://github.com/TerriaJS/TerriaMap.git +git clone -b proj4leaflet https://github.com/TerriaJS/TerriaMap.git cd TerriaMap TERRIAMAP_COMMIT_HASH=$(git rev-parse HEAD) sed -i -e 's@"terriajs": ".*"@"terriajs": "'$GITHUB_REPOSITORY'#'${GITHUB_BRANCH}'"@g' package.json From e8698b724eb987b8ae8a9b453f37bef75b4d5a11 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 12 May 2025 15:53:53 +1000 Subject: [PATCH 16/31] Fix lint errors. --- lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts | 4 ++-- lib/ReactViewModels/ViewState.ts | 2 +- lib/ReactViews/Map/Panels/SettingPanel.tsx | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts b/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts index 4389c8f6ab6..c5ec3b81d3f 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts @@ -701,9 +701,9 @@ export default class WebMapServiceCapabilitiesStratum extends LoadableStratum( { minx: number; miny: number; maxx: number; maxy: number } > = {}; - for (let layer of layers) { + for (const layer of layers) { const layerBoxes = getLayerBoundingBoxes(layer); - for (let { crs, minx, miny, maxx, maxy } of layerBoxes) { + for (const { crs, minx, miny, maxx, maxy } of layerBoxes) { if (crsBoxes[crs]) { const current = crsBoxes[crs]; crsBoxes[crs] = { diff --git a/lib/ReactViewModels/ViewState.ts b/lib/ReactViewModels/ViewState.ts index 9b6a0ddc5b6..99e692a3186 100644 --- a/lib/ReactViewModels/ViewState.ts +++ b/lib/ReactViewModels/ViewState.ts @@ -164,7 +164,7 @@ export default class ViewState { * Do not rely on this; marked as private as it can change. */ @observable - _customMapViewOptions?: ComponentType<{}>; + _customMapViewOptions?: ComponentType; @action setSelectedTrainerItem(trainerItem: string): void { diff --git a/lib/ReactViews/Map/Panels/SettingPanel.tsx b/lib/ReactViews/Map/Panels/SettingPanel.tsx index bf94b2d88e2..e9fc63a7ebc 100644 --- a/lib/ReactViews/Map/Panels/SettingPanel.tsx +++ b/lib/ReactViews/Map/Panels/SettingPanel.tsx @@ -4,7 +4,6 @@ import Slider from "rc-slider"; import { ChangeEvent, ComponentProps, - ComponentType, FC, MouseEvent, Ref, @@ -27,10 +26,10 @@ import { GLYPHS, StyledIcon } from "../../../Styled/Icon"; import Spacing from "../../../Styled/Spacing"; import Text, { TextSpan } from "../../../Styled/Text"; import { useViewState } from "../../Context"; +import RaiseToUserErrorBoundary from "../../Errors/RaiseToUserErrorBoundary"; import { useRefForTerria } from "../../Hooks/useRefForTerria"; import MenuPanel from "../../StandardUserInterface/customizable/MenuPanel"; import Styles from "./setting-panel.scss"; -import RaiseToUserErrorBoundary from "../../Errors/RaiseToUserErrorBoundary"; const sides = { left: "settingPanel.terrain.left", From fc7d196d8751d835f109dd5dbc4a1d923e455047 Mon Sep 17 00:00:00 2001 From: Nanda Date: Wed, 28 May 2025 15:43:14 +1000 Subject: [PATCH 17/31] Fix specs. --- .../Catalog/Ows/WebMapServiceCatalogItem.ts | 15 ++++++++------- lib/Models/Terria.ts | 3 ++- test/Models/TerriaSpec.ts | 5 ++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts index 59d879a0ffb..ffc53546383 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts @@ -6,7 +6,7 @@ // Solution: think in terms of pipelines with computed observables, document patterns. // 4. All code for all catalog item types needs to be loaded before we can do anything. import i18next from "i18next"; -import { computed, makeObservable, override, runInAction } from "mobx"; +import { computed, makeObservable, override, runInAction, trace } from "mobx"; import { computedFn } from "mobx-utils"; import GeographicTilingScheme from "terriajs-cesium/Source/Core/GeographicTilingScheme"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; @@ -549,12 +549,12 @@ class WebMapServiceCatalogItem return undefined; } - // Return if the CRS is not supported by this model - // - // TODO: this check might be too strict and could break the current - // behaviour where terria might use one of the supported CRS variants or - // fallback to EPSG:3857. - if (crsCode && !this.availableCrs?.includes(crsCode)) { + // Return undefined if the selected CRS is not supported by this model + if ( + crsCode && + this.availableCrs && + !this.availableCrs.includes(crsCode) + ) { return undefined; } @@ -660,6 +660,7 @@ class WebMapServiceCatalogItem ); private _createImageryProvider(time: string | undefined) { + trace(); return this._createImageryProviderForCrs(time, this.crs); } diff --git a/lib/Models/Terria.ts b/lib/Models/Terria.ts index 76ccc7f206f..b2310d83718 100644 --- a/lib/Models/Terria.ts +++ b/lib/Models/Terria.ts @@ -1807,10 +1807,11 @@ export default class Terria { ); } if (isJsonString(initData.settings.baseMapId)) { + const baseMapId = initData.settings.baseMapId; this._loadPersistedSettings.baseMapPromise = this.mainViewer .setBaseMap( this.baseMapsModel.baseMapItems.find( - (item) => item.item.uniqueId === initData.settings!.baseMapId + (item) => item.item.uniqueId === baseMapId )?.item ) .finally(() => { diff --git a/test/Models/TerriaSpec.ts b/test/Models/TerriaSpec.ts index 1ea25396801..6b58735ca7d 100644 --- a/test/Models/TerriaSpec.ts +++ b/test/Models/TerriaSpec.ts @@ -1550,7 +1550,10 @@ describe("Terria", function () { }); it("uses `settings` in initsource", async () => { - const setBaseMapSpy = spyOn(terria.mainViewer, "setBaseMap"); + const setBaseMapSpy = spyOn( + terria.mainViewer, + "setBaseMap" + ).and.callThrough(); await terria.start({ configUrl: "" }); From a535eec4760797d29e4354ccb1d4b130116bb143 Mon Sep 17 00:00:00 2001 From: Nanda Date: Fri, 15 Aug 2025 17:27:35 +1000 Subject: [PATCH 18/31] UI feedback changes for custom projection. --- lib/ModelMixins/MappableMixin.ts | 16 +- .../Ows/WebMapServiceCapabilitiesStratum.ts | 33 +++- .../Catalog/Ows/WebMapServiceCatalogItem.ts | 12 -- lib/Models/ViewerMode.ts | 4 +- lib/Models/Workbench.ts | 17 +- lib/ReactViewModels/NotificationState.ts | 22 ++- lib/ReactViewModels/ViewState.ts | 24 +-- .../Custom/FeatureLinkCustomComponent.ts | 35 ++++ lib/ReactViews/Map/Panels/SettingPanel.tsx | 157 +++++++++++++++--- lib/ReactViews/Notification/Notification.tsx | 17 +- .../Notification/NotificationToast.tsx | 88 ++++++++++ lib/ReactViews/Preview/DataPreviewMap.tsx | 8 + .../Preview/DataPreviewSections.jsx | 13 +- lib/ReactViews/Preview/data-preview-map.scss | 10 ++ .../Preview/data-preview-map.scss.d.ts | 1 + lib/Styled/Icon.tsx | 4 +- lib/Traits/TraitsClasses/BaseMapTraits.ts | 8 + lib/Traits/TraitsClasses/MappableTraits.ts | 24 +++ wwwroot/images/icons/warning.svg | 4 + 19 files changed, 423 insertions(+), 74 deletions(-) create mode 100644 lib/ReactViews/Custom/FeatureLinkCustomComponent.ts create mode 100644 lib/ReactViews/Notification/NotificationToast.tsx create mode 100644 wwwroot/images/icons/warning.svg diff --git a/lib/ModelMixins/MappableMixin.ts b/lib/ModelMixins/MappableMixin.ts index 5abb1280cd0..ae16a902c84 100644 --- a/lib/ModelMixins/MappableMixin.ts +++ b/lib/ModelMixins/MappableMixin.ts @@ -181,13 +181,6 @@ function MappableMixin>(Base: T) { */ async loadMapItems(force?: boolean): Promise> { try { - runInAction(() => { - if (this.shouldShowInitialMessage) { - // Don't await the initialMessage because this causes cyclic dependency between loading - // and user interaction (see https://github.com/TerriaJS/terriajs/issues/5528) - this.showInitialMessage(); - } - }); if (CatalogMemberMixin.isMixedInto(this)) (await this.loadMetadata()).throwIfError(); @@ -235,8 +228,15 @@ function MappableMixin>(Base: T) { : undefined, message: this.initialMessage.content ?? "", key: "initialMessage:" + this.initialMessage.key, - confirmAction: () => resolve() + confirmAction: () => resolve(), + showAsToast: this.initialMessage.showAsToast, + toastVisibleDuration: this.initialMessage.toastVisibleDuration }); + + // No need to wait for confirmation if the message is a toast + if (this.initialMessage.showAsToast) { + resolve(); + } }); } diff --git a/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts b/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts index c5ec3b81d3f..f686b67e5bc 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCapabilitiesStratum.ts @@ -14,7 +14,8 @@ import isReadOnlyArray from "../../../Core/isReadOnlyArray"; import { terriaTheme } from "../../../ReactViews/StandardUserInterface/StandardTheme"; import { InfoSectionTraits, - MetadataUrlTraits + MetadataUrlTraits, + ShortReportTraits } from "../../../Traits/TraitsClasses/CatalogMemberTraits"; import { ProjectedBoundingBoxTraits } from "../../../Traits/TraitsClasses/CrsTraits"; import { @@ -47,6 +48,7 @@ import WebMapServiceCapabilities, { getRectangleFromLayer } from "./WebMapServiceCapabilities"; import WebMapServiceCatalogItem from "./WebMapServiceCatalogItem"; +import { GeographicTilingScheme } from "terriajs-cesium"; /** Transforms WMS GetCapabilities XML into WebMapServiceCatalogItemTraits */ export default class WebMapServiceCapabilitiesStratum extends LoadableStratum( @@ -623,14 +625,31 @@ export default class WebMapServiceCapabilitiesStratum extends LoadableStratum( } @computed - get shortReport() { + get shortReport(): string | undefined { + if ( + this.catalogItem.tilingScheme instanceof GeographicTilingScheme && + this.catalogItem.terria.currentViewer.type === "Leaflet" + ) { + return i18next.t("map.cesium.notWebMercatorTilingScheme", this); + } + } + + @computed + get shortReportSections() { const catalogItem = this.catalogItem; - if (catalogItem.isShowingDiff) { - const format = "yyyy/mm/dd"; - const d1 = dateFormat(catalogItem.firstDiffDate, format); - const d2 = dateFormat(catalogItem.secondDiffDate, format); - return `Showing difference image computed for ${catalogItem.diffStyleId} style on dates ${d1} and ${d2}`; + if (!catalogItem.isShowingDiff) { + return; } + + const format = "yyyy/mm/dd"; + const d1 = dateFormat(catalogItem.firstDiffDate, format); + const d2 = dateFormat(catalogItem.secondDiffDate, format); + return [ + createStratumInstance(ShortReportTraits, { + name: "Difference", + content: `Showing difference image computed for ${catalogItem.diffStyleId} style on dates ${d1} and ${d2}` + }) + ]; } @computed diff --git a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts index ffc53546383..c9c69c5a8d6 100644 --- a/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts +++ b/lib/Models/Catalog/Ows/WebMapServiceCatalogItem.ts @@ -8,7 +8,6 @@ import i18next from "i18next"; import { computed, makeObservable, override, runInAction, trace } from "mobx"; import { computedFn } from "mobx-utils"; -import GeographicTilingScheme from "terriajs-cesium/Source/Core/GeographicTilingScheme"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; import TilingScheme from "terriajs-cesium/Source/Core/TilingScheme"; import combine from "terriajs-cesium/Source/Core/combine"; @@ -180,17 +179,6 @@ class WebMapServiceCatalogItem return WebMapServiceCatalogItem.type; } - @override - get shortReport(): string | undefined { - if ( - this.tilingScheme instanceof GeographicTilingScheme && - this.terria.currentViewer.type === "Leaflet" - ) { - return i18next.t("map.cesium.notWebMercatorTilingScheme", this); - } - return super.shortReport; - } - @computed get colorScaleRange(): string | undefined { if (this.supportsColorScaleRange) { diff --git a/lib/Models/ViewerMode.ts b/lib/Models/ViewerMode.ts index d8cf74e4d2c..f8dd49bd517 100644 --- a/lib/Models/ViewerMode.ts +++ b/lib/Models/ViewerMode.ts @@ -55,8 +55,8 @@ export function getViewerType(viewerMode: string): ViewerMode | undefined { // Note: // There is small naming ambiguity here // ViewerMode can either mean Leaflet|Cesium|NoViewer or 3d|3dsmooth|2d - // The 3d|2d sense of viewermode is used in APIs, for eg as a localStorage - // So I think we should renmae Leaflet|Cesium... to viewerType + // The 3d|2d sense of viewermode is used in APIs, for eg to set preference in localStorage + // So I think we should renmae Leaflet|Cesium... to viewerType instead if (isViewerMode(viewerMode)) { return MapViewers[viewerMode].viewerMode; } diff --git a/lib/Models/Workbench.ts b/lib/Models/Workbench.ts index b1957272c8b..70c51178720 100644 --- a/lib/Models/Workbench.ts +++ b/lib/Models/Workbench.ts @@ -1,5 +1,11 @@ import i18next from "i18next"; -import { action, computed, observable, makeObservable } from "mobx"; +import { + action, + computed, + observable, + makeObservable, + runInAction +} from "mobx"; import filterOutUndefined from "../Core/filterOutUndefined"; import Result from "../Core/Result"; import TerriaError, { TerriaErrorSeverity } from "../Core/TerriaError"; @@ -211,6 +217,10 @@ export default class Workbench { }); } + if (MappableMixin.isMixedInto(item) && item.shouldShowInitialMessage) { + await item.showInitialMessage(); + } + this.insertItem(item); let error: TerriaError | undefined; @@ -240,7 +250,10 @@ export default class Workbench { if (!error && MappableMixin.isMixedInto(item)) { error = (await item.loadMapItems()).error; - if (!error && item.zoomOnAddToWorkbench && !item.disableZoomTo) { + const shouldZoom = runInAction( + () => item.zoomOnAddToWorkbench && !item.disableZoomTo + ); + if (!error && shouldZoom) { item.terria.currentViewer.zoomTo(item); } } diff --git a/lib/ReactViewModels/NotificationState.ts b/lib/ReactViewModels/NotificationState.ts index c70db71bd7a..ae882eb5312 100644 --- a/lib/ReactViewModels/NotificationState.ts +++ b/lib/ReactViewModels/NotificationState.ts @@ -14,9 +14,27 @@ export interface Notification { width?: number | string; height?: number | string; key?: string; - /** If notification should not be shown to the user */ + + /** Show notification as a toast instead of as a blocking message */ + showAsToast?: boolean; + + /** + * Duration in seconds after which the toast is dismissed. If undefined, the + * toast must be explicitly dismissed by the user. + */ + toastVisibleDuration?: number; + + /** + * True if notification should not be shown to the user. You can also pass a + * reactive function which will dismiss the message even if it is currently + * being shown to the user. + */ ignore?: boolean | (() => boolean); - /** Called when notification is dismissed, this will also be triggered for confirm/deny actions */ + + /** + * Called when notification is dismissed, this will also be triggered for + * confirm/deny actions + */ onDismiss?: () => void; } diff --git a/lib/ReactViewModels/ViewState.ts b/lib/ReactViewModels/ViewState.ts index 99e692a3186..840f5db22d4 100644 --- a/lib/ReactViewModels/ViewState.ts +++ b/lib/ReactViewModels/ViewState.ts @@ -1,45 +1,45 @@ import { + IReactionDisposer, action, computed, - IReactionDisposer, + makeObservable, observable, reaction, - runInAction, - makeObservable + runInAction } from "mobx"; -import { ReactNode, MouseEvent, ComponentType, Ref } from "react"; +import { ComponentType, MouseEvent, ReactNode, Ref } from "react"; import defined from "terriajs-cesium/Source/Core/defined"; -import addedByUser from "../Core/addedByUser"; import { Category, HelpAction, StoryAction } from "../Core/AnalyticEvents/analyticEvents"; import Result from "../Core/Result"; +import addedByUser from "../Core/addedByUser"; import triggerResize from "../Core/triggerResize"; import PickedFeatures from "../Map/PickedFeatures/PickedFeatures"; import CatalogMemberMixin, { getName } from "../ModelMixins/CatalogMemberMixin"; import GroupMixin from "../ModelMixins/GroupMixin"; import MappableMixin from "../ModelMixins/MappableMixin"; import ReferenceMixin from "../ModelMixins/ReferenceMixin"; +import CatalogSearchProviderMixin from "../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; +import CzmlCatalogItem from "../Models/Catalog/CatalogItems/CzmlCatalogItem"; import CommonStrata from "../Models/Definition/CommonStrata"; import { BaseModel } from "../Models/Definition/Model"; -import getAncestors from "../Models/getAncestors"; +import { getMarkerCatalogItem } from "../Models/LocationMarkerUtils"; import { SelectableDimension } from "../Models/SelectableDimensions/SelectableDimensions"; import Terria from "../Models/Terria"; import { ViewingControl } from "../Models/ViewingControls"; +import getAncestors from "../Models/getAncestors"; import { SATELLITE_HELP_PROMPT_KEY } from "../ReactViews/HelpScreens/SatelliteHelpPrompt"; import { animationDuration } from "../ReactViews/StandardUserInterface/StandardUserInterface"; import { FeatureInfoPanelButtonGenerator } from "../ViewModels/FeatureInfoPanel"; +import SearchState from "./SearchState"; import { - defaultTourPoints, RelativePosition, - TourPoint + TourPoint, + defaultTourPoints } from "./defaultTourPoints"; -import SearchState from "./SearchState"; -import CatalogSearchProviderMixin from "../ModelMixins/SearchProviders/CatalogSearchProviderMixin"; -import { getMarkerCatalogItem } from "../Models/LocationMarkerUtils"; -import CzmlCatalogItem from "../Models/Catalog/CatalogItems/CzmlCatalogItem"; export const DATA_CATALOG_NAME = "data-catalog"; export const USER_DATA_NAME = "my-data"; diff --git a/lib/ReactViews/Custom/FeatureLinkCustomComponent.ts b/lib/ReactViews/Custom/FeatureLinkCustomComponent.ts new file mode 100644 index 00000000000..f5b01453b9a --- /dev/null +++ b/lib/ReactViews/Custom/FeatureLinkCustomComponent.ts @@ -0,0 +1,35 @@ +import { createElement, ReactElement } from "react"; +import { TooltipWithButtonLauncher } from "../Generic/TooltipWrapper"; +import CustomComponent, { + DomElement, + ProcessNodeContext +} from "./CustomComponent"; + +/** + * A `` custom component, taking a title and content + * around its child components. It has the following attributes: + * + * * `title` - (Required) The text to use as the "tooltip launcher" + */ +export default class FeatureLinkCustomComponent extends CustomComponent { + get name(): string { + return "feature"; + } + + get attributes(): string[] { + return ["id"]; + } + + processNode( + _context: ProcessNodeContext, + node: DomElement, + children: ReactElement[] + ): ReactElement { + /* eslint-disable-next-line react/no-children-prop */ + return createElement(TooltipWithButtonLauncher, { + dismissOnLeave: true, + launcherComponent: () => node.attribs?.title, + children: () => children + }); + } +} diff --git a/lib/ReactViews/Map/Panels/SettingPanel.tsx b/lib/ReactViews/Map/Panels/SettingPanel.tsx index e9fc63a7ebc..16506b85458 100644 --- a/lib/ReactViews/Map/Panels/SettingPanel.tsx +++ b/lib/ReactViews/Map/Panels/SettingPanel.tsx @@ -12,11 +12,15 @@ import { import { useTranslation } from "react-i18next"; import styled from "styled-components"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; +import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin"; import MappableMixin from "../../../ModelMixins/MappableMixin"; +import { BaseMapItem } from "../../../Models/BaseMaps/BaseMapsModel"; import Cesium from "../../../Models/Cesium"; -import { BaseModel } from "../../../Models/Definition/Model"; +import hasTraits from "../../../Models/Definition/hasTraits"; +import Terria from "../../../Models/Terria"; import ViewerMode, { MapViewers, + getViewerType, setViewerMode } from "../../../Models/ViewerMode"; import Box from "../../../Styled/Box"; @@ -25,8 +29,9 @@ import Checkbox from "../../../Styled/Checkbox"; import { GLYPHS, StyledIcon } from "../../../Styled/Icon"; import Spacing from "../../../Styled/Spacing"; import Text, { TextSpan } from "../../../Styled/Text"; +import CrsTraits from "../../../Traits/TraitsClasses/CrsTraits"; +import TerriaViewer from "../../../ViewModels/TerriaViewer"; import { useViewState } from "../../Context"; -import RaiseToUserErrorBoundary from "../../Errors/RaiseToUserErrorBoundary"; import { useRefForTerria } from "../../Hooks/useRefForTerria"; import MenuPanel from "../../StandardUserInterface/customizable/MenuPanel"; import Styles from "./setting-panel.scss"; @@ -45,22 +50,41 @@ const SettingPanel: FC = observer(() => { SETTING_PANEL_NAME, viewState ); - const [hoverBaseMap, setHoverBaseMap] = useState(null); + const [hoverBaseMap, setHoverBaseMap] = useState< + MappableMixin.Instance | undefined + >(); - const activeMapName = hoverBaseMap - ? hoverBaseMap - : terria.mainViewer.baseMap - ? (terria.mainViewer.baseMap as any).name - : "(None)"; + const activeMap = hoverBaseMap ?? terria.mainViewer.baseMap; + const activeMapName = activeMap ? mapDisplayName(activeMap) : "(None)"; const selectBaseMap = ( - baseMap: BaseModel, + baseMap: MappableMixin.Instance, event: MouseEvent ) => { event.stopPropagation(); if (!MappableMixin.isMixedInto(baseMap)) return; - terria.mainViewer.setBaseMap(baseMap); + const currentViewerMode = terria.mainViewer.viewerMode; + const newViewerMode = baseMap.preferredViewerMode + ? getViewerType(baseMap.preferredViewerMode) + : undefined; + + terria.mainViewer.setBaseMap(baseMap).then(() => { + const switchedViewerMode = + newViewerMode && + currentViewerMode && + newViewerMode !== currentViewerMode && + terria.mainViewer.viewerMode === newViewerMode; + + if (switchedViewerMode) { + notifyViewerModeSwitch( + baseMap, + currentViewerMode, + newViewerMode, + terria + ); + } + }); // We store the user's chosen basemap for future use, but it's up to the instance to decide // whether to use that at start up. @@ -72,12 +96,12 @@ const SettingPanel: FC = observer(() => { } }; - const mouseEnterBaseMap = (baseMap: any) => { - setHoverBaseMap(baseMap.item?.name); + const mouseEnterBaseMap = (baseMap: BaseMapItem) => { + setHoverBaseMap(baseMap.item); }; const mouseLeaveBaseMap = () => { - setHoverBaseMap(null); + setHoverBaseMap(undefined); }; const selectViewer = action( @@ -86,6 +110,12 @@ const SettingPanel: FC = observer(() => { event.stopPropagation(); showTerrainOnSide(sides.both, undefined); setViewerMode(viewer, mainViewer); + ensureComaptibleBaseMapForViewer( + viewer, + mainViewer.viewerMode, + mainViewer, + terria + ); // We store the user's chosen viewer mode for future use. terria.setLocalProperty("viewermode", viewer); terria.currentViewer.notifyRepaintRequired(); @@ -204,8 +234,6 @@ const SettingPanel: FC = observer(() => { ? t("settingPanel.timeline.alwaysShowLabel") : t("settingPanel.timeline.hideLabel"); - const CustomMapViewOptions = viewState._customMapViewOptions; - return ( //@ts-expect-error - not yet ready to tackle tsfying MenuPanel { ))} - {CustomMapViewOptions && ( - - - - )} {!!supportsSide && ( <> @@ -311,6 +334,11 @@ const SettingPanel: FC = observer(() => { ))} + {terria.baseMapsModel.statusMessage && ( + + {terria.baseMapsModel.statusMessage} + + )} <> @@ -379,6 +407,90 @@ const SettingPanel: FC = observer(() => { ); }); +/** + * Return name + CRS if the base map specifies one + */ +function mapDisplayName(baseMap: MappableMixin.Instance): string { + const name = + (CatalogMemberMixin.isMixedInto(baseMap) ? baseMap.name : undefined) ?? ""; + const crs = hasTraits(baseMap, CrsTraits, "crs") ? baseMap.crs : undefined; + return name && crs ? `${name} (${crs})` : name; +} + +/** + * Ensure the base map is compatible with the viewer mode user has selected + * + * If the current base map specifies a `preferredViewerMode` and this doesn't + * match the new viewer mode then switch to a compatible base map. + */ +function ensureComaptibleBaseMapForViewer( + newViewerMode: "3d" | "3dsmooth" | "2d", + newViewerType: ViewerMode | undefined, + viewer: TerriaViewer, + terria: Terria +) { + const preferredViewerMode = viewer.baseMap?.preferredViewerMode; + if (!preferredViewerMode || preferredViewerMode === newViewerMode) { + return; + } + + const newBaseMap = terria.baseMapsModel.baseMapItems.find((it) => { + const baseMap = it.item; + return ( + !baseMap.preferredViewerMode || + baseMap.preferredViewerMode === newViewerMode + ); + })?.item; + + if (newBaseMap) { + const lastBaseMapName = mapDisplayName(viewer.baseMap); + const newBaseMapName = mapDisplayName(newBaseMap); + const mapMode = newViewerMode.slice(0, 2).toUpperCase(); // 3D | 2D; + const message = `The '${lastBaseMapName}' base map cannot be viewed in ${mapMode}. The Base Map has + been changed to '${newBaseMapName}'.`; + const title = "Base map switched"; + + viewer.setBaseMap(newBaseMap).then(() => { + // If already showing a similar notification, dismiss it to show a new one. + const currentNotification = terria.notificationState.currentNotification; + if (currentNotification?.title === title) { + terria.notificationState.dismissCurrentNotification(); + } + + terria.notificationState.addNotificationToQueue({ + title, + message, + ignore: () => + viewer.baseMap !== newBaseMap || viewer.viewerMode !== newViewerType, + showAsToast: true, + toastVisibleDuration: 15 + }); + }); + } +} + +function notifyViewerModeSwitch( + newBaseMap: MappableMixin.Instance, + currentViewerMode: ViewerMode, + newViewerMode: ViewerMode, + terria: Terria +) { + const viewer = terria.mainViewer; + const currentMode = currentViewerMode === ViewerMode.Cesium ? "3D" : "2D"; + const newMode = newViewerMode === ViewerMode.Cesium ? "3D" : "2D"; + const newBaseMapName = mapDisplayName(newBaseMap); + const message = `The ‘${newBaseMapName}’ base map cannot be viewed in ${currentMode}. The Map View has been changed to ${newMode}.`; + + terria.notificationState.addNotificationToQueue({ + title: "", + message, + ignore: () => + viewer.viewerMode !== newViewerMode || viewer.baseMap !== newBaseMap, + showAsToast: true, + toastVisibleDuration: 15 + }); +} + export const SETTING_PANEL_NAME = "MenuBarMapSettingsButton"; export default SettingPanel; @@ -420,3 +532,8 @@ const StyledImage = styled(Box).attrs({ })>` border-radius: inherit; `; + +const BaseMapStatus = styled(Text)` + font-size: 11px; + padding: ${(p) => p.theme.padding} 0; +`; diff --git a/lib/ReactViews/Notification/Notification.tsx b/lib/ReactViews/Notification/Notification.tsx index 36242bd9448..3038dd672f3 100644 --- a/lib/ReactViews/Notification/Notification.tsx +++ b/lib/ReactViews/Notification/Notification.tsx @@ -1,6 +1,8 @@ import { observer } from "mobx-react"; +import { useEffect } from "react"; import triggerResize from "../../Core/triggerResize"; import { useViewState } from "../Context"; +import NotificationToast from "./NotificationToast"; import NotificationWindow from "./NotificationWindow"; const Notification = observer(() => { @@ -8,6 +10,17 @@ const Notification = observer(() => { const notificationState = viewState?.terria.notificationState; const notification = notificationState?.currentNotification; + const ignore = + typeof notification?.ignore === "function" + ? notification.ignore() + : notification?.ignore ?? false; + + useEffect(() => { + if (ignore) { + notificationState.dismissCurrentNotification(); + } + }, [notificationState, ignore]); + if ( viewState === undefined || notificationState === undefined || @@ -38,7 +51,9 @@ const Notification = observer(() => { close(); }; - return ( + return notification.showAsToast ? ( + + ) : ( = ({ notification }) => { + const viewState = useViewState(); + const nodeRef = useRef(null); + + const notificationState = viewState.terria.notificationState; + const durationMsecs = notification.toastVisibleDuration + ? notification.toastVisibleDuration * 1000 + : undefined; + + const message = + typeof notification.message === "function" + ? notification.message(viewState) + : notification.message; + + useEffect(() => { + const timeout = setTimeout(() => { + if (notificationState.currentNotification === notification) { + notificationState.dismissCurrentNotification(); + } + }, durationMsecs); + return () => clearTimeout(timeout); + }, [notification, notificationState, durationMsecs]); + + return ( + + +
{message}
+ { + e.stopPropagation(); + notificationState.dismissCurrentNotification(); + }} + /> +
+ ); +}; + +const Wrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + + position: fixed; + bottom: 70px; + left: 50%; + transform: translate(-35%); + border: 1px solid #ea580c; + border-radius: 6px; + z-index: ${(p) => p.theme.notificationWindowZIndex}; + + max-width: 50%; + padding: 16px; + gap: 16px; + background-color: #f2f2f2; +`; + +const CloseButton = styled(Button).attrs({ + styledWidth: "24px", + styledHeight: "24px", + renderIcon: () => ( + + ) +})` + background-color: transparent; + border: 0; + min-height: max-content; +`; + +export default NotificationToast; diff --git a/lib/ReactViews/Preview/DataPreviewMap.tsx b/lib/ReactViews/Preview/DataPreviewMap.tsx index e884cbb3a43..4a740f71c58 100644 --- a/lib/ReactViews/Preview/DataPreviewMap.tsx +++ b/lib/ReactViews/Preview/DataPreviewMap.tsx @@ -41,6 +41,10 @@ const DataPreviewMap: FC = observer((props) => { }, [terria, previewed, homeCamera]); useEffect(() => { + if (!showMap) { + return; + } + const container = mapContainerRef.current; const baseMapItems = terria.baseMapsModel.baseMapItems; @@ -65,6 +69,7 @@ const DataPreviewMap: FC = observer((props) => { } }; }, [ + showMap, previewViewer, terria.baseMapsModel.baseMapItems, terria.baseMapsModel.previewBaseMapId @@ -124,6 +129,9 @@ const DataPreviewMap: FC = observer((props) => { + {previewed?.previewCaption && ( +
{previewed?.previewCaption}
+ )} ); }); diff --git a/lib/ReactViews/Preview/DataPreviewSections.jsx b/lib/ReactViews/Preview/DataPreviewSections.jsx index c65f0164b8a..87d24eecf77 100644 --- a/lib/ReactViews/Preview/DataPreviewSections.jsx +++ b/lib/ReactViews/Preview/DataPreviewSections.jsx @@ -100,13 +100,12 @@ class DataPreviewSections extends Component { } bodyTextProps={{ medium: true }} > - {item.content?.length > 0 - ? renderSection(item) - : item.contentAsObject !== undefined && ( - - - - )} + {item.content?.length > 0 && renderSection(item)} + {item.contentAsObject !== undefined && ( + + + + )} ))} diff --git a/lib/ReactViews/Preview/data-preview-map.scss b/lib/ReactViews/Preview/data-preview-map.scss index 3619280fcfb..044f2c6b8d8 100644 --- a/lib/ReactViews/Preview/data-preview-map.scss +++ b/lib/ReactViews/Preview/data-preview-map.scss @@ -14,6 +14,16 @@ background: #fff; } +.caption { + bottom: 5px; + left: 5px; + padding: 3px 5px 3px 5px; + height: 16px; + font-size: 75%; + position: absolute; + background: #fff; +} + //Preview map .terria-preview { &:global(.leaflet-container) { diff --git a/lib/ReactViews/Preview/data-preview-map.scss.d.ts b/lib/ReactViews/Preview/data-preview-map.scss.d.ts index 0aa9558165e..fcf2ebdc33a 100644 --- a/lib/ReactViews/Preview/data-preview-map.scss.d.ts +++ b/lib/ReactViews/Preview/data-preview-map.scss.d.ts @@ -1,6 +1,7 @@ declare namespace DataPreviewMapScssNamespace { export interface IDataPreviewMapScss { badge: string; + caption: string; map: string; placeholder: string; "terria-preview": string; diff --git a/lib/Styled/Icon.tsx b/lib/Styled/Icon.tsx index 9aaf3ac40f4..948d67e682b 100644 --- a/lib/Styled/Icon.tsx +++ b/lib/Styled/Icon.tsx @@ -145,6 +145,7 @@ import minusList from "../../wwwroot/images/icons/dismiss-20.svg"; import switchOn from "../../wwwroot/images/icons/switch-on.svg"; import switchOff from "../../wwwroot/images/icons/switch-off.svg"; import dragDrop from "../../wwwroot/images/icons/drag-drop.svg"; +import warning from "../../wwwroot/images/icons/warning.svg"; // Icon export const GLYPHS = { @@ -289,7 +290,8 @@ export const GLYPHS = { minusList, switchOn, switchOff, - dragDrop + dragDrop, + warning }; export interface IconGlyph { diff --git a/lib/Traits/TraitsClasses/BaseMapTraits.ts b/lib/Traits/TraitsClasses/BaseMapTraits.ts index f315334ef3e..386d2ae9ab2 100644 --- a/lib/Traits/TraitsClasses/BaseMapTraits.ts +++ b/lib/Traits/TraitsClasses/BaseMapTraits.ts @@ -71,4 +71,12 @@ export class BaseMapsTraits extends ModelTraits { "Array of base maps ids that is available to user. Use this do define order of the base maps in settings panel. Leave undefined to show all basemaps." }) enabledBaseMaps?: string[]; + + @primitiveTrait({ + type: "string", + name: "statusMessage", + description: + "A short message to show next to the basemap selector in map settings panel" + }) + statusMessage?: string; } diff --git a/lib/Traits/TraitsClasses/MappableTraits.ts b/lib/Traits/TraitsClasses/MappableTraits.ts index 64da45913d3..230b84911d1 100644 --- a/lib/Traits/TraitsClasses/MappableTraits.ts +++ b/lib/Traits/TraitsClasses/MappableTraits.ts @@ -146,6 +146,7 @@ export class IdealZoomTraits extends ModelTraits { }) camera?: CameraTraits; } + export class InitialMessageTraits extends ModelTraits { @primitiveTrait({ type: "string", @@ -197,6 +198,21 @@ export class InitialMessageTraits extends ModelTraits { description: "Height of the message." }) height?: number; + + @primitiveTrait({ + type: "boolean", + name: "Show as toast message", + description: "Show the initial message as a toast" + }) + showAsToast?: boolean = false; + + @primitiveTrait({ + type: "number", + name: "Toast visible duration", + description: + "Time in seconds after which the toast will be dismissed. If undefined, user must take action." + }) + toastVisibleDuration?: number; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ @@ -289,6 +305,14 @@ class MappableTraits extends mixTraits(AttributionTraits) { "The preferred viewer mode for this item - either '2d' '3d' or '3dsmooth'. If this dataset is used as a basemap then we automatically switch the viewer to the preferred mode. However the user can still switch to another mode, so this preference is not strongly enforced." }) preferredViewerMode?: string; + + @primitiveTrait({ + type: "string", + name: "Preview caption", + description: + "Caption text for the preview map shown in bottom left corner of the preview map." + }) + previewCaption?: string; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ diff --git a/wwwroot/images/icons/warning.svg b/wwwroot/images/icons/warning.svg new file mode 100644 index 00000000000..e5e50a86b96 --- /dev/null +++ b/wwwroot/images/icons/warning.svg @@ -0,0 +1,4 @@ + + + + From 8504d1a1476bbd99ce93923a6a93ae4b79edf99c Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 18 Aug 2025 14:50:06 +1000 Subject: [PATCH 19/31] Simplify toast notification. --- lib/ReactViews/Notification/Notification.tsx | 72 +++++++++---------- .../Notification/NotificationToast.tsx | 27 ++++--- .../SlideUpFadeIn/AnimateSlideUpFadeIn.tsx | 34 +++++++++ 3 files changed, 85 insertions(+), 48 deletions(-) create mode 100644 lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx diff --git a/lib/ReactViews/Notification/Notification.tsx b/lib/ReactViews/Notification/Notification.tsx index 3038dd672f3..628a466fb9b 100644 --- a/lib/ReactViews/Notification/Notification.tsx +++ b/lib/ReactViews/Notification/Notification.tsx @@ -4,6 +4,7 @@ import triggerResize from "../../Core/triggerResize"; import { useViewState } from "../Context"; import NotificationToast from "./NotificationToast"; import NotificationWindow from "./NotificationWindow"; +import AnimateSlideUpFadeIn from "../Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn"; const Notification = observer(() => { const viewState = useViewState(); @@ -21,51 +22,48 @@ const Notification = observer(() => { } }, [notificationState, ignore]); - if ( - viewState === undefined || - notificationState === undefined || - notification === undefined - ) { - return null; - } - const close = () => { - // Force refresh once the notification is dispatched if .hideUi is set since once all the .hideUi's - // have been dispatched the UI will no longer be suppressed causing a change in the view state. + if (!notification) return; + + // Force refresh once the notification is dispatched if .hideUi is set + // since once all the .hideUi's have been dispatched the UI will no longer + // be suppressed causing a change in the view state. + if (notification.hideUi) { triggerResize(); } notificationState.dismissCurrentNotification(); }; - const confirm = () => { - if (notification.confirmAction !== undefined) { - notification.confirmAction(); - } - close(); - }; - const deny = () => { - if (notification.denyAction !== undefined) { - notification.denyAction(); - } - close(); - }; - return notification.showAsToast ? ( - - ) : ( - + return ( + <> + + notification ? ( + + ) : null + } + /> + {notification && !notification.showAsToast && ( + { + notification.confirmAction?.(); + close(); + }} + onDeny={() => notification.denyAction?.()} + type={notification.type ?? "notification"} + width={notification.width} + height={notification.height} + /> + )} + ); }); diff --git a/lib/ReactViews/Notification/NotificationToast.tsx b/lib/ReactViews/Notification/NotificationToast.tsx index 2be4caf1912..a6aa7a5b772 100644 --- a/lib/ReactViews/Notification/NotificationToast.tsx +++ b/lib/ReactViews/Notification/NotificationToast.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useRef } from "react"; +import { FC, useEffect } from "react"; import styled from "styled-components"; import { Notification } from "../../ReactViewModels/NotificationState"; import { Button } from "../../Styled/Button"; @@ -9,19 +9,24 @@ const NotificationToast: FC<{ notification: Notification; }> = ({ notification }) => { const viewState = useViewState(); - const nodeRef = useRef(null); const notificationState = viewState.terria.notificationState; - const durationMsecs = notification.toastVisibleDuration - ? notification.toastVisibleDuration * 1000 - : undefined; + const durationMsecs = + notification && notification.toastVisibleDuration !== undefined + ? notification.toastVisibleDuration * 1000 + : undefined; - const message = - typeof notification.message === "function" + const message = notification + ? typeof notification.message === "function" ? notification.message(viewState) - : notification.message; + : notification?.message + : undefined; useEffect(() => { + if (durationMsecs === undefined) { + return; + } + const timeout = setTimeout(() => { if (notificationState.currentNotification === notification) { notificationState.dismissCurrentNotification(); @@ -31,7 +36,7 @@ const NotificationToast: FC<{ }, [notification, notificationState, durationMsecs]); return ( - + p.theme.notificationWindowZIndex}; diff --git a/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx b/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx new file mode 100644 index 00000000000..5cb724f4bd3 --- /dev/null +++ b/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx @@ -0,0 +1,34 @@ +import { FC, ReactNode, useState } from "react"; +import { SlideUpFadeIn } from "./SlideUpFadeIn"; + +interface PropsType { + /** + * Triggers enter animation when true and exit animation when false + */ + isVisible: boolean; + + /** + * The component to render when the animation enters + */ + renderOnVisible: () => ReactNode; +} + +/** + * A wrapper for SlideUpFadeIn + */ +const AnimateSlideUpFadeIn: FC = ({ + isVisible, + renderOnVisible +}) => { + const [children, setChildren] = useState(null); + return ( + setChildren(renderOnVisible())} + > + <>{children} + + ); +}; + +export default AnimateSlideUpFadeIn; From 7d5d18ec634a2013cf1a7a83e0dfbecbae59e3f0 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 18 Aug 2025 14:50:47 +1000 Subject: [PATCH 20/31] Set leaflet container background color from base map settings. --- lib/Models/BaseMaps/BaseMapsModel.ts | 2 ++ .../LeafletContainerStyle.tsx | 31 +++++++++++++++++++ .../TerriaViewerWrapper.tsx | 10 ++++-- lib/Traits/TraitsClasses/BaseMapTraits.ts | 8 +++++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx diff --git a/lib/Models/BaseMaps/BaseMapsModel.ts b/lib/Models/BaseMaps/BaseMapsModel.ts index 457ba232984..8340cbf7ea8 100644 --- a/lib/Models/BaseMaps/BaseMapsModel.ts +++ b/lib/Models/BaseMaps/BaseMapsModel.ts @@ -35,6 +35,7 @@ export type BaseMapsJson = Partial< export interface BaseMapItem { image?: string; contrastColor?: string; + backgroundColor?: string; item: MappableMixin.Instance; } @@ -65,6 +66,7 @@ export class BaseMapsModel extends CreateModel(BaseMapsTraits) { enabledBaseMaps.push({ image: baseMapItem.image, contrastColor: baseMapItem.contrastColor, + backgroundColor: baseMapItem.backgroundColor, item: itemModel }); } diff --git a/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx b/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx new file mode 100644 index 00000000000..b59d13b2125 --- /dev/null +++ b/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx @@ -0,0 +1,31 @@ +import { observer } from "mobx-react"; +import { FC } from "react"; +import { createGlobalStyle } from "styled-components"; +import { useViewState } from "terriajs-plugin-api"; + +/** + * Injects global styles for leaflet container based on map state. + */ +const LeafletContainerStyle: FC = observer(() => { + const terria = useViewState().terria; + + // Derive background color from active base map + const backgroundColor = terria.baseMapsModel.baseMapItems.find( + (b) => b.item === terria.mainViewer.baseMap + )?.backgroundColor; + + return ; +}); + +/** + * Defines global styles for leaflet map container + */ +const LeafletContainerGlobalStyle = createGlobalStyle<{ + backgroundColor?: string; +}>` + .leaflet-container { + background-color: ${(props) => props.backgroundColor ?? "#ddd"} + } +`; + +export default LeafletContainerStyle; diff --git a/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx b/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx index 7d2602ae88b..f34e6a60532 100644 --- a/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx +++ b/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx @@ -1,8 +1,9 @@ import { FC, useEffect, useRef } from "react"; - -import { Splitter } from "./Splitter/Splitter"; -import { useViewState } from "../../Context"; import styled from "styled-components"; +import { useViewState } from "../../Context"; +import LeafletContainerStyle from "./LeafletContainerStyle"; +import { Splitter } from "./Splitter/Splitter"; +import { ViewerMode } from "terriajs-plugin-api"; export const TerriaViewerWrapper: FC = () => { const viewState = useViewState(); @@ -27,6 +28,9 @@ export const TerriaViewerWrapper: FC = () => { Loading the map, please wait... + {viewState.terria.mainViewer.viewerMode === ViewerMode.Leaflet && ( + + )}
Date: Mon, 18 Aug 2025 15:00:35 +1000 Subject: [PATCH 21/31] Fix lint. --- .../Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx b/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx index 5cb724f4bd3..c8758ac4156 100644 --- a/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx +++ b/lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx @@ -26,6 +26,7 @@ const AnimateSlideUpFadeIn: FC = ({ isVisible={isVisible} onEnter={() => setChildren(renderOnVisible())} > + {/* eslint-disable-next-line react/jsx-no-useless-fragment -- react-transition-group expects a single child */} <>{children} ); From fae22ffa16894cdc2da973fbd77a76e5a7e0ae6b Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 18 Aug 2025 17:08:39 +1000 Subject: [PATCH 22/31] Fix type. --- .../Map/TerriaViewerWrapper/LeafletContainerStyle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx b/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx index b59d13b2125..1fa3400bc5e 100644 --- a/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx +++ b/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx @@ -6,7 +6,7 @@ import { useViewState } from "terriajs-plugin-api"; /** * Injects global styles for leaflet container based on map state. */ -const LeafletContainerStyle: FC = observer(() => { +const LeafletContainerStyle: FC = observer(() => { const terria = useViewState().terria; // Derive background color from active base map From 6fded2cbaf5ece46c23ad81d81eba28666572678 Mon Sep 17 00:00:00 2001 From: Nanda Date: Mon, 18 Aug 2025 17:51:34 +1000 Subject: [PATCH 23/31] Fix import. --- .../Map/TerriaViewerWrapper/LeafletContainerStyle.tsx | 2 +- lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx b/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx index 1fa3400bc5e..0f3a4095e92 100644 --- a/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx +++ b/lib/ReactViews/Map/TerriaViewerWrapper/LeafletContainerStyle.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; import { FC } from "react"; import { createGlobalStyle } from "styled-components"; -import { useViewState } from "terriajs-plugin-api"; +import { useViewState } from "../../Context/ViewStateContext"; /** * Injects global styles for leaflet container based on map state. diff --git a/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx b/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx index f34e6a60532..d7597f4f0f6 100644 --- a/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx +++ b/lib/ReactViews/Map/TerriaViewerWrapper/TerriaViewerWrapper.tsx @@ -1,9 +1,9 @@ import { FC, useEffect, useRef } from "react"; import styled from "styled-components"; +import ViewerMode from "../../../Models/ViewerMode"; import { useViewState } from "../../Context"; import LeafletContainerStyle from "./LeafletContainerStyle"; import { Splitter } from "./Splitter/Splitter"; -import { ViewerMode } from "terriajs-plugin-api"; export const TerriaViewerWrapper: FC = () => { const viewState = useViewState(); From ff5561b3c397dcd11e1ec36fc3f163386e2b8669 Mon Sep 17 00:00:00 2001 From: Nanda Date: Tue, 19 Aug 2025 10:05:19 +1000 Subject: [PATCH 24/31] Use SlideUpFadeIn. --- lib/ReactViews/Notification/Notification.tsx | 15 ++++---- .../SlideUpFadeIn/AnimateSlideUpFadeIn.tsx | 35 ------------------- 2 files changed, 6 insertions(+), 44 deletions(-) delete mode 100644 lib/ReactViews/Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn.tsx diff --git a/lib/ReactViews/Notification/Notification.tsx b/lib/ReactViews/Notification/Notification.tsx index 628a466fb9b..923e1d730b7 100644 --- a/lib/ReactViews/Notification/Notification.tsx +++ b/lib/ReactViews/Notification/Notification.tsx @@ -2,9 +2,9 @@ import { observer } from "mobx-react"; import { useEffect } from "react"; import triggerResize from "../../Core/triggerResize"; import { useViewState } from "../Context"; +import { SlideUpFadeIn } from "../Transitions/SlideUpFadeIn/SlideUpFadeIn"; import NotificationToast from "./NotificationToast"; import NotificationWindow from "./NotificationWindow"; -import AnimateSlideUpFadeIn from "../Transitions/SlideUpFadeIn/AnimateSlideUpFadeIn"; const Notification = observer(() => { const viewState = useViewState(); @@ -38,14 +38,11 @@ const Notification = observer(() => { return ( <> - - notification ? ( - - ) : null - } - /> + +
+ {notification && } +
+
{notification && !notification.showAsToast && ( ReactNode; -} - -/** - * A wrapper for SlideUpFadeIn - */ -const AnimateSlideUpFadeIn: FC = ({ - isVisible, - renderOnVisible -}) => { - const [children, setChildren] = useState(null); - return ( - setChildren(renderOnVisible())} - > - {/* eslint-disable-next-line react/jsx-no-useless-fragment -- react-transition-group expects a single child */} - <>{children} - - ); -}; - -export default AnimateSlideUpFadeIn; From 4afef3c1b6670b16c90a36e6ccfd7aed8cec425b Mon Sep 17 00:00:00 2001 From: Nanda Date: Tue, 19 Aug 2025 10:26:52 +1000 Subject: [PATCH 25/31] Save base map preference on viewer switch. --- lib/ReactViews/Map/Panels/SettingPanel.tsx | 23 ++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/ReactViews/Map/Panels/SettingPanel.tsx b/lib/ReactViews/Map/Panels/SettingPanel.tsx index 16506b85458..bef2d341c53 100644 --- a/lib/ReactViews/Map/Panels/SettingPanel.tsx +++ b/lib/ReactViews/Map/Panels/SettingPanel.tsx @@ -88,12 +88,7 @@ const SettingPanel: FC = observer(() => { // We store the user's chosen basemap for future use, but it's up to the instance to decide // whether to use that at start up. - if (baseMap) { - const baseMapId = baseMap.uniqueId; - if (baseMapId) { - terria.setLocalProperty("basemap", baseMapId); - } - } + saveBaseMapPreference(terria, baseMap); }; const mouseEnterBaseMap = (baseMap: BaseMapItem) => { @@ -451,6 +446,10 @@ function ensureComaptibleBaseMapForViewer( const title = "Base map switched"; viewer.setBaseMap(newBaseMap).then(() => { + // User did not explicitly choose this base map but we still save it for + // consistency. + saveBaseMapPreference(terria, newBaseMap); + // If already showing a similar notification, dismiss it to show a new one. const currentNotification = terria.notificationState.currentNotification; if (currentNotification?.title === title) { @@ -491,6 +490,18 @@ function notifyViewerModeSwitch( }); } +/** + * Save user's base map preference + */ +function saveBaseMapPreference( + terria: Terria, + baseMap: MappableMixin.Instance +) { + if (baseMap.uniqueId) { + terria.setLocalProperty("basemap", baseMap.uniqueId); + } +} + export const SETTING_PANEL_NAME = "MenuBarMapSettingsButton"; export default SettingPanel; From 5cae1dadecf13dfcc44acbf613eadd26eff6ae0b Mon Sep 17 00:00:00 2001 From: Nanda Date: Tue, 19 Aug 2025 14:02:19 +1000 Subject: [PATCH 26/31] Add link component. --- lib/ReactViewModels/ViewState.ts | 10 +++++ .../Custom/FeatureLinkCustomComponent.ts | 35 ---------------- .../SettingsPanelLinkCustomComponent.ts | 40 +++++++++++++++++++ .../Custom/registerCustomComponentTypes.ts | 2 + lib/ReactViews/Generic/FeatureLink.tsx | 37 +++++++++++++++++ lib/ReactViews/Map/Panels/SettingPanel.tsx | 4 ++ 6 files changed, 93 insertions(+), 35 deletions(-) delete mode 100644 lib/ReactViews/Custom/FeatureLinkCustomComponent.ts create mode 100644 lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts create mode 100644 lib/ReactViews/Generic/FeatureLink.tsx diff --git a/lib/ReactViewModels/ViewState.ts b/lib/ReactViewModels/ViewState.ts index 840f5db22d4..e37fb903261 100644 --- a/lib/ReactViewModels/ViewState.ts +++ b/lib/ReactViewModels/ViewState.ts @@ -376,6 +376,8 @@ export default class ViewState { */ @observable retainSharePanel: boolean = false; // The large share panel accessed via Share/Print button + @observable settingsPanelIsVisible: boolean = false; + /** * The currently open tool */ @@ -622,6 +624,14 @@ export default class ViewState { this.searchState.searchCatalog(); } + /** + * Open settings panel + */ + @action + openSettingsPanel(): void { + this.settingsPanelIsVisible = true; + } + @action clearPreviewedItem(): void { this.userDataPreviewedItem = undefined; diff --git a/lib/ReactViews/Custom/FeatureLinkCustomComponent.ts b/lib/ReactViews/Custom/FeatureLinkCustomComponent.ts deleted file mode 100644 index f5b01453b9a..00000000000 --- a/lib/ReactViews/Custom/FeatureLinkCustomComponent.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createElement, ReactElement } from "react"; -import { TooltipWithButtonLauncher } from "../Generic/TooltipWrapper"; -import CustomComponent, { - DomElement, - ProcessNodeContext -} from "./CustomComponent"; - -/** - * A `` custom component, taking a title and content - * around its child components. It has the following attributes: - * - * * `title` - (Required) The text to use as the "tooltip launcher" - */ -export default class FeatureLinkCustomComponent extends CustomComponent { - get name(): string { - return "feature"; - } - - get attributes(): string[] { - return ["id"]; - } - - processNode( - _context: ProcessNodeContext, - node: DomElement, - children: ReactElement[] - ): ReactElement { - /* eslint-disable-next-line react/no-children-prop */ - return createElement(TooltipWithButtonLauncher, { - dismissOnLeave: true, - launcherComponent: () => node.attribs?.title, - children: () => children - }); - } -} diff --git a/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts b/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts new file mode 100644 index 00000000000..523d97f52e7 --- /dev/null +++ b/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts @@ -0,0 +1,40 @@ +import { action } from "mobx"; +import { createElement, ReactElement } from "react"; +import { ViewState } from "terriajs-plugin-api"; +import FeatureLink from "../Generic/FeatureLink"; +import CustomComponent, { + DomElement, + ProcessNodeContext +} from "./CustomComponent"; + +/** + * A `` custom component, that shows a link like button which when clicked + * opens the settings panel. + * + * Props accepted + * - title - The text to use as alt link content + * - children - The link text + * + * Example: Change base map + */ +export default class SettingsPanelLinkCustomComponent extends CustomComponent { + get name(): string { + return "settingspanel"; + } + + get attributes(): string[] { + return ["title"]; + } + + processNode( + _context: ProcessNodeContext, + node: DomElement, + children: ReactElement[] + ): ReactElement { + return createElement(FeatureLink, { + title: node.attribs?.title, + children, + onClick: action((viewState: ViewState) => viewState.openSettingsPanel()) + }); + } +} diff --git a/lib/ReactViews/Custom/registerCustomComponentTypes.ts b/lib/ReactViews/Custom/registerCustomComponentTypes.ts index 42f6df306fd..56f03adbcdb 100644 --- a/lib/ReactViews/Custom/registerCustomComponentTypes.ts +++ b/lib/ReactViews/Custom/registerCustomComponentTypes.ts @@ -8,6 +8,7 @@ import CsvChartCustomComponent from "./CsvChartCustomComponent"; import CustomComponent from "./CustomComponent"; import FeedbackLinkCustomComponent from "./FeedbackLinkCustomComponent"; import SOSChartCustomComponent from "./SOSChartCustomComponent"; +import SettingsPanelLinkCustomComponent from "./SettingsPanelLinkCustomComponent"; import TerriaTooltipCustomComponent from "./TerriaTooltip"; /** @@ -23,6 +24,7 @@ export default function registerCustomComponentTypes(terria?: Terria) { CustomComponent.register(new ApiTableChartCustomComponent()); CustomComponent.register(new CollapsibleCustomComponent()); CustomComponent.register(new FeedbackLinkCustomComponent()); + CustomComponent.register(new SettingsPanelLinkCustomComponent()); CustomComponent.register(new TerriaTooltipCustomComponent()); // At the time this is called `cesiumIonOAuth2ApplicationID` won't be populated yet. diff --git a/lib/ReactViews/Generic/FeatureLink.tsx b/lib/ReactViews/Generic/FeatureLink.tsx new file mode 100644 index 00000000000..58c1356070e --- /dev/null +++ b/lib/ReactViews/Generic/FeatureLink.tsx @@ -0,0 +1,37 @@ +import { FC, ReactNode } from "react"; +import styled from "styled-components"; +import { ViewState, useViewState } from "terriajs-plugin-api"; + +interface PropsType { + title?: string; + children: ReactNode | ReactNode[]; + onClick: (viewState: ViewState) => void; +} + +/** + * A button as link that provides common styling for custom component types + * that open a feature. + */ +const FeatureLink: FC = ({ title, onClick, children }) => { + const viewState = useViewState(); + return ( + { + e.stopPropagation(); + onClick(viewState); + }} + > + {children} + + ); +}; + +const ButtonAsLink = styled.button` + background: none; + border: none; + text-decoration: underline dashed; + color: inherit; +`; + +export default FeatureLink; diff --git a/lib/ReactViews/Map/Panels/SettingPanel.tsx b/lib/ReactViews/Map/Panels/SettingPanel.tsx index bef2d341c53..cffc4e65a3b 100644 --- a/lib/ReactViews/Map/Panels/SettingPanel.tsx +++ b/lib/ReactViews/Map/Panels/SettingPanel.tsx @@ -238,6 +238,10 @@ const SettingPanel: FC = observer(() => { btnText={t("settingPanel.btnText")} viewState={viewState} smallScreen={viewState.useSmallScreenInterface} + isOpen={viewState.settingsPanelIsVisible} + onOpenChanged={action((isOpen: boolean) => { + viewState.settingsPanelIsVisible = isOpen; + })} > From dae68df2f0becd8b9ec8168d1d9461f7cf532f0f Mon Sep 17 00:00:00 2001 From: Nanda Date: Tue, 19 Aug 2025 14:18:47 +1000 Subject: [PATCH 27/31] Fix imports. --- .../Custom/SettingsPanelLinkCustomComponent.ts | 15 +++++++++------ lib/ReactViews/Generic/FeatureLink.tsx | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts b/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts index 523d97f52e7..989ed92c5d9 100644 --- a/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts +++ b/lib/ReactViews/Custom/SettingsPanelLinkCustomComponent.ts @@ -1,6 +1,6 @@ import { action } from "mobx"; import { createElement, ReactElement } from "react"; -import { ViewState } from "terriajs-plugin-api"; +import ViewState from "../../ReactViewModels/ViewState"; import FeatureLink from "../Generic/FeatureLink"; import CustomComponent, { DomElement, @@ -31,10 +31,13 @@ export default class SettingsPanelLinkCustomComponent extends CustomComponent { node: DomElement, children: ReactElement[] ): ReactElement { - return createElement(FeatureLink, { - title: node.attribs?.title, - children, - onClick: action((viewState: ViewState) => viewState.openSettingsPanel()) - }); + return createElement( + FeatureLink, + { + title: node.attribs?.title, + onClick: action((viewState: ViewState) => viewState.openSettingsPanel()) + }, + ...children + ); } } diff --git a/lib/ReactViews/Generic/FeatureLink.tsx b/lib/ReactViews/Generic/FeatureLink.tsx index 58c1356070e..229899ea30a 100644 --- a/lib/ReactViews/Generic/FeatureLink.tsx +++ b/lib/ReactViews/Generic/FeatureLink.tsx @@ -1,10 +1,11 @@ import { FC, ReactNode } from "react"; import styled from "styled-components"; -import { ViewState, useViewState } from "terriajs-plugin-api"; +import ViewState from "../../ReactViewModels/ViewState"; +import { useViewState } from "../Context/ViewStateContext"; interface PropsType { title?: string; - children: ReactNode | ReactNode[]; + children?: ReactNode | ReactNode[]; onClick: (viewState: ViewState) => void; } From 3c0c48f3505ac26c9a373cc01c45dad242265136 Mon Sep 17 00:00:00 2001 From: Nanda Date: Thu, 21 Aug 2025 19:03:55 +1000 Subject: [PATCH 28/31] Customizable workbench controls. --- .../Map/MapNavigation/Items/MyLocation.ts | 2 +- .../Workbench/Controls/ViewingControls.tsx | 30 +++-- .../Workbench/Controls/WorkbenchControls.ts | 76 +++++++++++++ .../Controls/WorkbenchItemControls.tsx | 104 +++++++----------- .../Workflow/SelectableDimensionWorkflow.tsx | 9 +- .../TraitsClasses/CatalogMemberTraits.ts | 6 + 6 files changed, 145 insertions(+), 82 deletions(-) create mode 100644 lib/ReactViews/Workbench/Controls/WorkbenchControls.ts diff --git a/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts b/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts index fcf12d0cd81..e1228f47444 100644 --- a/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts +++ b/lib/ReactViews/Map/MapNavigation/Items/MyLocation.ts @@ -122,7 +122,7 @@ export class MyLocation extends MapNavigationItemController { coordinates: [longitude, latitude] }, properties: { - title: t("location.location"), + title: t("location.location"), longitude: longitude, latitude: latitude } diff --git a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx index 4b0db877b79..e6db3005b73 100644 --- a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx +++ b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx @@ -4,19 +4,19 @@ import { observer } from "mobx-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import createGuid from "terriajs-cesium/Source/Core/createGuid"; import defined from "terriajs-cesium/Source/Core/defined"; -import Rectangle from "terriajs-cesium/Source/Core/Rectangle"; import SplitDirection from "terriajs-cesium/Source/Scene/SplitDirection"; import { Category, DataSourceAction } from "../../../Core/AnalyticEvents/analyticEvents"; +import TerriaError from "../../../Core/TerriaError"; import filterOutUndefined from "../../../Core/filterOutUndefined"; import getDereferencedIfExists from "../../../Core/getDereferencedIfExists"; import getPath from "../../../Core/getPath"; import isDefined from "../../../Core/isDefined"; -import TerriaError from "../../../Core/TerriaError"; import CatalogMemberMixin, { getName } from "../../../ModelMixins/CatalogMemberMixin"; @@ -26,13 +26,13 @@ import MappableMixin from "../../../ModelMixins/MappableMixin"; import SearchableItemMixin from "../../../ModelMixins/SearchableItemMixin"; import TimeVarying from "../../../ModelMixins/TimeVarying"; import CameraView from "../../../Models/CameraView"; -import addUserCatalogMember from "../../../Models/Catalog/addUserCatalogMember"; import SplitItemReference from "../../../Models/Catalog/CatalogReferences/SplitItemReference"; +import addUserCatalogMember from "../../../Models/Catalog/addUserCatalogMember"; import CommonStrata from "../../../Models/Definition/CommonStrata"; -import hasTraits from "../../../Models/Definition/hasTraits"; import Model, { BaseModel } from "../../../Models/Definition/Model"; -import getAncestors from "../../../Models/getAncestors"; +import hasTraits from "../../../Models/Definition/hasTraits"; import { ViewingControl } from "../../../Models/ViewingControls"; +import getAncestors from "../../../Models/getAncestors"; import ViewState from "../../../ReactViewModels/ViewState"; import AnimatedSpinnerIcon from "../../../Styled/AnimatedSpinnerIcon"; import Box from "../../../Styled/Box"; @@ -44,6 +44,7 @@ import SplitterTraits from "../../../Traits/TraitsClasses/SplitterTraits"; import { exportData } from "../../Preview/ExportData"; import LazyItemSearchTool from "../../Tools/ItemSearchTool/LazyItemSearchTool"; import WorkbenchButton from "../WorkbenchButton"; +import { WorkbenchControlSet, buildControlSet } from "./WorkbenchControls"; const BoxViewingControl = styled(Box).attrs({ centered: true, @@ -91,10 +92,11 @@ const ViewingControlMenuButton = styled(RawButton).attrs({ interface PropsType { viewState: ViewState; item: BaseModel; + controls?: WorkbenchControlSet; } const ViewingControls: React.FC = observer((props) => { - const { viewState, item } = props; + const { viewState, item, controls = buildControlSet() } = props; const { t } = useTranslation(); const [isMenuOpen, setIsOpen] = useState(false); const [isMapZoomingToCatalogItem, setIsMapZoomingToCatalogItem] = @@ -328,11 +330,12 @@ const ViewingControls: React.FC = observer((props) => { return sortBy( uniqBy([...itemViewingControls, ...globalViewingControls], "id"), "name" - ); + ).filter(({ id }) => controls[id] === true); }, [item, viewState.globalViewingControlOptions]); const renderViewingControlsMenu = () => { const canSplit = + controls.compare && !item.terria.configParameters.disableSplitter && hasTraits(item, SplitterTraits, "splitDirection") && hasTraits(item, SplitterTraits, "disableSplitter") && @@ -376,7 +379,8 @@ const ViewingControls: React.FC = observer((props) => { ) : null} - {viewState.useSmallScreenInterface === false && + {controls.difference && + viewState.useSmallScreenInterface === false && DiffableMixin.isMixedInto(item) && !item.isShowingDiff && item.canDiffImages ? ( @@ -392,7 +396,8 @@ const ViewingControls: React.FC = observer((props) => { ) : null} - {viewState.useSmallScreenInterface === false && + {controls.exportData && + viewState.useSmallScreenInterface === false && ExportableMixin.isMixedInto(item) && item.canExportData ? (
  • @@ -407,7 +412,8 @@ const ViewingControls: React.FC = observer((props) => {
  • ) : null} - {viewState.useSmallScreenInterface === false && + {controls.search && + viewState.useSmallScreenInterface === false && SearchableItemMixin.isMixedInto(item) && item.canSearch ? (
  • @@ -464,6 +470,7 @@ const ViewingControls: React.FC = observer((props) => { onClick={zoomTo} title={t("workbench.zoomToTitle")} disabled={ + !controls.idealZoom || // disabled if the item cannot be zoomed to or if a zoom is already in progress (MappableMixin.isMixedInto(item) && item.disableZoomTo) || isMapZoomingToCatalogItem === true @@ -483,7 +490,8 @@ const ViewingControls: React.FC = observer((props) => { title={t("workbench.previewItemTitle")} iconElement={() => } disabled={ - CatalogMemberMixin.isMixedInto(item) && item.disableAboutData + !controls.aboutData || + (CatalogMemberMixin.isMixedInto(item) && item.disableAboutData) } > {t("workbench.previewItem")} diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts b/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts new file mode 100644 index 00000000000..77ff7cb0afc --- /dev/null +++ b/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts @@ -0,0 +1,76 @@ +/** + * Default visibility for statically known workbench controls + */ +const defaultControls = { + // When true, disable all controls + disableAll: false, + + // Viewing control flags + viewingControlsGroup: true, // when false, disable all viewing controls + compare: true, // Flag for compare tool also known as splitter + difference: true, // Flag for difference tool + idealZoom: true, + aboutData: true, + exportData: true, + search: true, // Flag for item search tool + + opacity: true, + scaleWorkbench: true, + timer: true, + chartItems: true, + filter: true, + dateTime: true, + timeFilter: true, + selectableDimensions: true, + colorScaleRange: true, + shortReport: true, + legend: true +}; + +/** + * WorkbenchControls can be any of the static flags defined in defaultControls + * or the ID of any dynamic control. + */ +export type WorkbenchControls = Partial< + typeof defaultControls & { + [dynamicControlId: string]: boolean; + } +>; + +/** + * A complete set of WorkbenchControls + */ +export type WorkbenchControlSet = Required; + +/** + * Derives a complete flag set from partial flags + * + * @param controls Optional partial flag set to override the defaults. If + * `disableAll` is `true` then all flags except group flags will be turned off. + */ +export function buildControlSet( + controls?: WorkbenchControls +): WorkbenchControlSet { + const { disableAll = false, ...overrides } = controls ?? {}; + const defaultValue = disableAll ? false : true; + + // Use a Proxy to handle undefined flags and dynamic flags + return new Proxy( + { + // Enable group controls by default even if disableAll is set so that we + // render the child controls if they are individually enabled. To fully + // disable the group, explicitly set it to false (through the `overrides` + // below). + viewingControlsGroup: true, + ...overrides, + disableAll + } as WorkbenchControlSet, + { + get(target, prop) { + if (typeof prop === "string") { + return !!(target[prop] ?? defaultValue); + } + } + } + ); +} diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx index ee7c5423a4b..5d26f600329 100644 --- a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx +++ b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx @@ -25,22 +25,9 @@ import DimensionSelectorSection from "./SelectableDimensionSection"; import ShortReport from "./ShortReport"; import TimerSection from "./TimerSection"; import ViewingControls from "./ViewingControls"; - -type WorkbenchControls = { - viewingControls?: boolean; - opacity?: boolean; - scaleWorkbench?: boolean; - splitter?: boolean; - timer?: boolean; - chartItems?: boolean; - filter?: boolean; - dateTime?: boolean; - timeFilter?: boolean; - selectableDimensions?: boolean; - colorScaleRange?: boolean; - shortReport?: boolean; - legend?: boolean; -}; +import { WorkbenchControls, buildControlSet } from "./WorkbenchControls"; +import { CatalogMemberMixin } from "terriajs-plugin-api"; +import { isJsonObject } from "../../../Core/Json"; type WorkbenchItemControlsProps = { item: BaseModel; @@ -49,42 +36,25 @@ type WorkbenchItemControlsProps = { controls?: WorkbenchControls; }; -export const defaultControls: Complete = { - viewingControls: true, - opacity: true, - scaleWorkbench: true, - splitter: true, - timer: true, - chartItems: true, - filter: true, - dateTime: true, - timeFilter: true, - selectableDimensions: true, - colorScaleRange: true, - shortReport: true, - legend: true -}; - -export const hideAllControls: Complete = { - viewingControls: false, - opacity: false, - scaleWorkbench: false, - splitter: false, - timer: false, - chartItems: false, - filter: false, - dateTime: false, - timeFilter: false, - selectableDimensions: false, - colorScaleRange: false, - shortReport: false, - legend: false -}; - const WorkbenchItemControls: FC = observer( - ({ item, viewState, controls: controlsWithoutDefaults }) => { + ({ item, viewState, controls: controlsProp }) => { // Apply controls from props on top of defaultControls - const controls = { ...defaultControls, ...controlsWithoutDefaults }; + const itemControls = + CatalogMemberMixin.isMixedInto(item) && + isJsonObject(item.workbenchControls) + ? (item.workbenchControls as WorkbenchControls) + : undefined; + + const controls = buildControlSet({ + ...controlsProp, + ...itemControls + }); + console.log( + "**controls**", + itemControls, + controls.disableAll, + controls.aboutData + ); const { generatedControls, error } = generateControls(viewState, item); if (error) { @@ -93,22 +63,26 @@ const WorkbenchItemControls: FC = observer( return ( <> - {controls?.viewingControls ? ( - + {controls.viewingControlsGroup ? ( + ) : null} - {controls?.opacity ? : null} - {controls?.scaleWorkbench ? : null} - {controls?.timer ? : null} - {controls?.splitter ? : null} - {controls?.chartItems ? : null} - {controls?.filter ? : null} - {controls?.dateTime && DiscretelyTimeVaryingMixin.isMixedInto(item) ? ( + {controls.opacity ? : null} + {controls.scaleWorkbench ? : null} + {controls.timer ? : null} + {controls.splitter ? : null} + {controls.chartItems ? : null} + {controls.filter ? : null} + {controls.dateTime && DiscretelyTimeVaryingMixin.isMixedInto(item) ? ( ) : null} - {controls?.timeFilter ? ( + {controls.timeFilter ? ( ) : null} - {controls?.selectableDimensions ? ( + {controls.selectableDimensions ? ( ) : null} { @@ -120,7 +94,7 @@ const WorkbenchItemControls: FC = observer( } {/* TODO: remove min max props and move the checks to ColorScaleRangeSection to keep this component simple. */} - {controls?.colorScaleRange && + {controls.colorScaleRange && hasTraits( item, WebMapServiceCatalogItemTraits, @@ -137,9 +111,9 @@ const WorkbenchItemControls: FC = observer( maxValue={item.colorScaleMaximum} /> )} - {controls?.shortReport ? : null} - {controls?.legend ? : null} - {controls?.selectableDimensions ? ( + {controls.shortReport ? : null} + {controls.legend ? : null} + {controls.selectableDimensions ? ( ) : null} { diff --git a/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx b/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx index 8d6460641bf..bd10f92c821 100644 --- a/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx +++ b/lib/ReactViews/Workflow/SelectableDimensionWorkflow.tsx @@ -4,11 +4,9 @@ import { FC } from "react"; import { useTranslation } from "react-i18next"; import { getName } from "../../ModelMixins/CatalogMemberMixin"; import { filterSelectableDimensions } from "../../Models/SelectableDimensions/SelectableDimensions"; -import SelectableDimension from "../SelectableDimensions/SelectableDimension"; import { useViewState } from "../Context"; -import WorkbenchItemControls, { - hideAllControls -} from "../Workbench/Controls/WorkbenchItemControls"; +import SelectableDimension from "../SelectableDimensions/SelectableDimension"; +import WorkbenchItemControls from "../Workbench/Controls/WorkbenchItemControls"; import { Panel } from "./Panel"; import { PanelMenu } from "./PanelMenu"; import WorkflowPanel from "./WorkflowPanel"; @@ -46,7 +44,8 @@ const SelectableDimensionWorkflow: FC = observer(() => { item={terria.selectableDimensionWorkflow.item} viewState={viewState} controls={{ - ...hideAllControls, + disableAll: true, + viewingControlsGroup: false, // disable viewingControls as a whole opacity: true, timer: true, dateTime: true, diff --git a/lib/Traits/TraitsClasses/CatalogMemberTraits.ts b/lib/Traits/TraitsClasses/CatalogMemberTraits.ts index 5455bc3eab2..10857d2e292 100644 --- a/lib/Traits/TraitsClasses/CatalogMemberTraits.ts +++ b/lib/Traits/TraitsClasses/CatalogMemberTraits.ts @@ -257,6 +257,12 @@ class CatalogMemberTraits extends ModelTraits { "True (default) if this catalog member may be included in share links. False to exclude it from share links." }) shareable: boolean = true; + + @anyTrait({ + name: "Workbench controls", + description: "Flags to enable or disable workbench controls." + }) + workbenchControls?: JsonObject; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ From 611385345884b0e997865f3fce2f05f04e9e2e78 Mon Sep 17 00:00:00 2001 From: Nanda Date: Fri, 22 Aug 2025 09:33:12 +1000 Subject: [PATCH 29/31] Fix CatalogMemberMixin import. --- .../Workbench/Controls/WorkbenchItemControls.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx index 5d26f600329..9ac9401f405 100644 --- a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx +++ b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx @@ -1,10 +1,11 @@ import { observer } from "mobx-react"; import { FC } from "react"; +import { isJsonObject } from "../../../Core/Json"; import TerriaError from "../../../Core/TerriaError"; -import { Complete } from "../../../Core/TypeModifiers"; +import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin"; import DiscretelyTimeVaryingMixin from "../../../ModelMixins/DiscretelyTimeVaryingMixin"; -import hasTraits from "../../../Models/Definition/hasTraits"; import { BaseModel } from "../../../Models/Definition/Model"; +import hasTraits from "../../../Models/Definition/hasTraits"; import { DEFAULT_PLACEMENT, SelectableDimension @@ -26,8 +27,6 @@ import ShortReport from "./ShortReport"; import TimerSection from "./TimerSection"; import ViewingControls from "./ViewingControls"; import { WorkbenchControls, buildControlSet } from "./WorkbenchControls"; -import { CatalogMemberMixin } from "terriajs-plugin-api"; -import { isJsonObject } from "../../../Core/Json"; type WorkbenchItemControlsProps = { item: BaseModel; From a2974bc98e84d0122917cdcfcb4af0fd591bea26 Mon Sep 17 00:00:00 2001 From: Nanda Date: Thu, 23 Oct 2025 12:04:02 +1100 Subject: [PATCH 30/31] Enable/disable workbench controls via traits. --- .../Workbench/Controls/ViewingControls.tsx | 15 ++- .../Workbench/Controls/WorkbenchControls.ts | 126 +++++++++++------- .../Controls/WorkbenchItemControls.tsx | 24 ++-- .../TraitsClasses/CatalogMemberTraits.ts | 7 +- 4 files changed, 104 insertions(+), 68 deletions(-) diff --git a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx index e6db3005b73..7158473a864 100644 --- a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx +++ b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx @@ -44,7 +44,11 @@ import SplitterTraits from "../../../Traits/TraitsClasses/SplitterTraits"; import { exportData } from "../../Preview/ExportData"; import LazyItemSearchTool from "../../Tools/ItemSearchTool/LazyItemSearchTool"; import WorkbenchButton from "../WorkbenchButton"; -import { WorkbenchControlSet, buildControlSet } from "./WorkbenchControls"; +import { + WorkbenchControls, + enableAllControls, + isControlEnabled +} from "./WorkbenchControls"; const BoxViewingControl = styled(Box).attrs({ centered: true, @@ -92,11 +96,11 @@ const ViewingControlMenuButton = styled(RawButton).attrs({ interface PropsType { viewState: ViewState; item: BaseModel; - controls?: WorkbenchControlSet; + controls?: WorkbenchControls; } const ViewingControls: React.FC = observer((props) => { - const { viewState, item, controls = buildControlSet() } = props; + const { viewState, item, controls = enableAllControls } = props; const { t } = useTranslation(); const [isMenuOpen, setIsOpen] = useState(false); const [isMapZoomingToCatalogItem, setIsMapZoomingToCatalogItem] = @@ -330,7 +334,10 @@ const ViewingControls: React.FC = observer((props) => { return sortBy( uniqBy([...itemViewingControls, ...globalViewingControls], "id"), "name" - ).filter(({ id }) => controls[id] === true); + ).filter(({ id }) => { + // Exclude disabled controls + return isControlEnabled(controls, id); + }); }, [item, viewState.globalViewingControlOptions]); const renderViewingControlsMenu = () => { diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts b/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts index 77ff7cb0afc..f5e6256881d 100644 --- a/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts +++ b/lib/ReactViews/Workbench/Controls/WorkbenchControls.ts @@ -1,19 +1,44 @@ /** - * Default visibility for statically known workbench controls + * Static and dynamic flags for enabling/disabling controls in the workbench */ -const defaultControls = { - // When true, disable all controls +export type WorkbenchControls = Partial<{ + // When true, disable all controls by default. You can then selectively + // enable/disable flags individually to override the default. + disableAll: boolean; + + viewingControlsGroup: boolean; // when false, disable all viewing controls + compare: boolean; // Flag for compare tool also known as splitter + difference: boolean; // Flag for difference tool + idealZoom: boolean; + aboutData: boolean; + exportData: boolean; + search: boolean; // Flag for item search tool + opacity: boolean; + scaleWorkbench: boolean; + timer: boolean; + chartItems: boolean; + filter: boolean; + dateTime: boolean; + timeFilter: boolean; + selectableDimensions: boolean; + colorScaleRange: boolean; + shortReport: boolean; + legend: boolean; + + [dynamicControl: string]: boolean | undefined; +}>; + +export const enableAllControls: WorkbenchControls = { disableAll: false, - // Viewing control flags viewingControlsGroup: true, // when false, disable all viewing controls + compare: true, // Flag for compare tool also known as splitter difference: true, // Flag for difference tool idealZoom: true, aboutData: true, exportData: true, search: true, // Flag for item search tool - opacity: true, scaleWorkbench: true, timer: true, @@ -27,50 +52,61 @@ const defaultControls = { legend: true }; -/** - * WorkbenchControls can be any of the static flags defined in defaultControls - * or the ID of any dynamic control. - */ -export type WorkbenchControls = Partial< - typeof defaultControls & { - [dynamicControlId: string]: boolean; - } ->; +const disableAllControls: WorkbenchControls = { + disableAll: true, + + // Keep viewing controls group enabled so that we can render any member of + // that group that has been selectively enabled. + viewingControlsGroup: true, + + compare: false, // Flag for compare tool also known as splitter + difference: false, // Flag for difference tool + idealZoom: false, + aboutData: false, + exportData: false, + search: false, // Flag for item search tool + opacity: false, + scaleWorkbench: false, + timer: false, + chartItems: false, + filter: false, + dateTime: false, + timeFilter: false, + selectableDimensions: false, + colorScaleRange: false, + shortReport: false, + legend: false +}; /** - * A complete set of WorkbenchControls + * Merge all control flags + * @param partialControls One or more control flags definition + * @returns a merged object */ -export type WorkbenchControlSet = Required; +export function mergeControls( + ...partialControls: Partial[] +): WorkbenchControls { + return partialControls.reduce((acc, controls) => { + return { + ...acc, + ...(controls.disableAll === true + ? disableAllControls + : controls.disableAll === false + ? enableAllControls + : {}), + ...controls + }; + }, enableAllControls) as WorkbenchControls; +} /** - * Derives a complete flag set from partial flags - * - * @param controls Optional partial flag set to override the defaults. If - * `disableAll` is `true` then all flags except group flags will be turned off. + * Check if a control is enabled in the given controls object */ -export function buildControlSet( - controls?: WorkbenchControls -): WorkbenchControlSet { - const { disableAll = false, ...overrides } = controls ?? {}; - const defaultValue = disableAll ? false : true; - - // Use a Proxy to handle undefined flags and dynamic flags - return new Proxy( - { - // Enable group controls by default even if disableAll is set so that we - // render the child controls if they are individually enabled. To fully - // disable the group, explicitly set it to false (through the `overrides` - // below). - viewingControlsGroup: true, - ...overrides, - disableAll - } as WorkbenchControlSet, - { - get(target, prop) { - if (typeof prop === "string") { - return !!(target[prop] ?? defaultValue); - } - } - } - ); +export function isControlEnabled( + controls: WorkbenchControls, + controlName: string +): boolean { + return controlName in controls + ? !!controls[controlName] + : !controls.disableAll; } diff --git a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx index 9ac9401f405..804b4ac296e 100644 --- a/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx +++ b/lib/ReactViews/Workbench/Controls/WorkbenchItemControls.tsx @@ -26,34 +26,26 @@ import DimensionSelectorSection from "./SelectableDimensionSection"; import ShortReport from "./ShortReport"; import TimerSection from "./TimerSection"; import ViewingControls from "./ViewingControls"; -import { WorkbenchControls, buildControlSet } from "./WorkbenchControls"; +import { WorkbenchControls, mergeControls } from "./WorkbenchControls"; type WorkbenchItemControlsProps = { item: BaseModel; viewState: ViewState; /** Flag to show each control - defaults to all true */ - controls?: WorkbenchControls; + controls?: Partial; }; const WorkbenchItemControls: FC = observer( - ({ item, viewState, controls: controlsProp }) => { - // Apply controls from props on top of defaultControls + ({ item, viewState, controls: propsControls = {} }) => { const itemControls = CatalogMemberMixin.isMixedInto(item) && isJsonObject(item.workbenchControls) - ? (item.workbenchControls as WorkbenchControls) - : undefined; + ? item.workbenchControls + : {}; + + // Overrides item controls with props controls + const controls = mergeControls(itemControls, propsControls); - const controls = buildControlSet({ - ...controlsProp, - ...itemControls - }); - console.log( - "**controls**", - itemControls, - controls.disableAll, - controls.aboutData - ); const { generatedControls, error } = generateControls(viewState, item); if (error) { diff --git a/lib/Traits/TraitsClasses/CatalogMemberTraits.ts b/lib/Traits/TraitsClasses/CatalogMemberTraits.ts index 10857d2e292..0031f97174a 100644 --- a/lib/Traits/TraitsClasses/CatalogMemberTraits.ts +++ b/lib/Traits/TraitsClasses/CatalogMemberTraits.ts @@ -1,11 +1,12 @@ import i18next from "i18next"; import { JsonObject } from "../../Core/Json"; +import { WorkbenchControls } from "../../ReactViews/Workbench/Controls/WorkbenchControls"; import anyTrait from "../Decorators/anyTrait"; import objectArrayTrait from "../Decorators/objectArrayTrait"; import primitiveArrayTrait from "../Decorators/primitiveArrayTrait"; import primitiveTrait from "../Decorators/primitiveTrait"; -import mixTraits from "../mixTraits"; import ModelTraits from "../ModelTraits"; +import mixTraits from "../mixTraits"; import EnumDimensionTraits from "./DimensionTraits"; export class MetadataUrlTraits extends ModelTraits { @@ -260,9 +261,9 @@ class CatalogMemberTraits extends ModelTraits { @anyTrait({ name: "Workbench controls", - description: "Flags to enable or disable workbench controls." + description: "Flags for enabling or disabling workbench controls." }) - workbenchControls?: JsonObject; + workbenchControls?: Partial | JsonObject; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ From dc4921564f968357c640254158d0c6e1fbb7f943 Mon Sep 17 00:00:00 2001 From: Nanda Date: Thu, 23 Oct 2025 12:27:32 +1100 Subject: [PATCH 31/31] Lint fix. --- lib/ReactViews/Workbench/Controls/ViewingControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx index 7158473a864..36f21d41c8a 100644 --- a/lib/ReactViews/Workbench/Controls/ViewingControls.tsx +++ b/lib/ReactViews/Workbench/Controls/ViewingControls.tsx @@ -338,7 +338,7 @@ const ViewingControls: React.FC = observer((props) => { // Exclude disabled controls return isControlEnabled(controls, id); }); - }, [item, viewState.globalViewingControlOptions]); + }, [item, controls, viewState.globalViewingControlOptions]); const renderViewingControlsMenu = () => { const canSplit =