diff --git a/code/src/io/data/data-layer.ts b/code/src/io/data/data-layer.ts index 237d6d210..b4a65f108 100644 --- a/code/src/io/data/data-layer.ts +++ b/code/src/io/data/data-layer.ts @@ -8,136 +8,154 @@ import { JsonArray, JsonObject } from 'types/json'; * map provider type (i.e. MapboxDataLayer, CesiumDataLayer). */ export abstract class DataLayer { - /** - * Unique ID of layer. - */ - public readonly id: string; - - /** - * Public facing name of layer. - */ - public readonly name: string; - - /** - * Source of the layer's data. - */ - public readonly source: LayerSource; - - /** - * The JSON object that originally defined this layer (unadjusted). - */ - public readonly definition: JsonObject; - - /** - * Zero-based display order. - */ - public order: number = 0; - - /** - * Type of interactions that are allowed ("all"|"hover-only"|"click-only"|"none") - */ - public interactions: string = "all"; - - // Stores all the injectable map properties as a mapping for ease of access - public injectableProperties?: InjectableMapProperties = {}; - - // Field for indicating their groupings - public grouping?: string; - - /** - * A cached visibility state that persists across map terrain - * changes. Should be updated whenever visibility is changed - * via the mapping API - */ - public cachedVisibility: boolean = true; - - /* - * Indicates if the parent is visible - */ - public isGroupExpanded: boolean; - - // Indicates if live updates are required for this layer - public isLive?: boolean = false; - - /** - * Initialise a new DataLayer instance. - * - * @param id Unique ID of layer. - * @param isGroupExpanded Indicates if the layer's group is expanded. - * @param source Source of the layer's data. - * @param definition The JSON object that originally defined this layer. - */ - constructor(id: string, isGroupExpanded: boolean, source: LayerSource, definition: object) { - this.id = id; - this.source = source; - this.isGroupExpanded = isGroupExpanded; - this.definition = definition as JsonObject; - this.name = this.definition["name"] as string; - - if (this.definition["order"]) { - this.order = this.definition["order"] as number; - } - - if (this.definition["grouping"]) { - this.grouping = this.definition["grouping"] as string; - } - - if ('live' in this.definition) { - if (typeof this.definition["live"] === 'boolean') { - this.isLive = this.definition["live"]; - } else if (typeof this.definition["live"] === 'string') { - // Parse string to boolean if necessary (e.g., "true" or "false") - this.isLive = this.definition["live"].toLowerCase() === 'true'; - } else { - // Fallback if "live" exists but is not a valid boolean or string - this.isLive = false; - } - } else { - // Handle missing "live" property by assigning a default value - this.isLive = false; // Default value when "live" is not present - } - - // Inject clickable state if indicated - const clickableState: boolean = (this.definition[Interactions.CLICKABLE] ?? true) as boolean; - const clickableProperty: MapboxClickableProperty = { style: [clickableState] }; - this.updateInjectableProperty(Interactions.CLICKABLE, clickableProperty) - - // Inject hover state if indicated - if (this.definition[Interactions.HOVER]) { - const hoverJsonArray: JsonArray = this.definition[Interactions.HOVER] as JsonArray; - if (hoverJsonArray.length !== 2) { - throw new Error(`Invalid hover property detected for layer: ${this.id}. The hover property should have two values.`); - } - const hoverProperty: MapboxHoverProperty = { style: ["case", ["==", ["get", "iri"], "[HOVERED-IRI]"], Number(hoverJsonArray[0]), Number(hoverJsonArray[1])] }; - this.updateInjectableProperty(Interactions.HOVER, hoverProperty) - } + /** + * Unique ID of layer. + */ + public readonly id: string; + + /** + * Public facing name of layer. + */ + public readonly name: string; + + /** + * Source of the layer's data. + */ + public readonly source: LayerSource; + + /** + * The JSON object that originally defined this layer (unadjusted). + */ + public readonly definition: JsonObject; + + /** + * Zero-based display order. + */ + public order: number = 0; + + /** + * Type of interactions that are allowed ("all"|"hover-only"|"click-only"|"none") + */ + public interactions: string = "all"; + + // Stores all the injectable map properties as a mapping for ease of access + public injectableProperties?: InjectableMapProperties = {}; + + // Field for indicating their groupings + public grouping?: string; + + /** + * A cached visibility state that persists across map terrain + * changes. Should be updated whenever visibility is changed + * via the mapping API + */ + public cachedVisibility: boolean = true; + + /* + * Indicates if the parent is visible + */ + public isGroupExpanded: boolean; + + // Indicates if live updates are required for this layer + public isLive?: boolean = false; + + // indicates whether this layer is a highlight layer (hidden in the tree) + public isAHighlightLayer: boolean; + + // the property to match on the layer (e.g. ogc_fid) to filter the selected feature + public highlightFeatureId: string; + + // indicates whether this layer has an accompanying highlight layer + public hasHighlight?: boolean = false; + + /** + * Initialise a new DataLayer instance. + * + * @param id Unique ID of layer. + * @param isGroupExpanded Indicates if the layer's group is expanded. + * @param source Source of the layer's data. + * @param definition The JSON object that originally defined this layer. + */ + constructor(id: string, isGroupExpanded: boolean, source: LayerSource, definition: object) { + this.id = id; + this.source = source; + this.isGroupExpanded = isGroupExpanded; + this.definition = definition as JsonObject; + this.name = this.definition["name"] as string; + + if (this.definition["order"]) { + this.order = this.definition["order"] as number; } - /** - * Retrieves the injectable property associated with the specific interaction type. - * - * @param {string} interactionType The type of interaction ("hover") - */ - public getInjectableProperty(interactionType: string): InjectableProperty { - return this.injectableProperties[interactionType]; + if (this.definition["grouping"]) { + this.grouping = this.definition["grouping"] as string; } - /** - * Check if the specific interaction type exists in this layer. - * - * @param {string} interactionType The type of interaction ("hover") - */ - public hasInjectableProperty(interactionType: string): boolean { - return interactionType in this.injectableProperties; + if ('live' in this.definition) { + if (typeof this.definition["live"] === 'boolean') { + this.isLive = this.definition["live"]; + } else if (typeof this.definition["live"] === 'string') { + // Parse string to boolean if necessary (e.g., "true" or "false") + this.isLive = this.definition["live"].toLowerCase() === 'true'; + } else { + // Fallback if "live" exists but is not a valid boolean or string + this.isLive = false; + } + } else { + // Handle missing "live" property by assigning a default value + this.isLive = false; // Default value when "live" is not present } - /** - * Update an injectable property for a specific interaction type and map type. - * - * @param {string} interactionType The type of interaction ("hover") - * @param {InjectableProperty} property An injectable property to add - */ - public updateInjectableProperty(interactionType: string, property: InjectableProperty): void { - this.injectableProperties[interactionType] = property; + // Inject clickable state if indicated + const clickableState: boolean = (this.definition[Interactions.CLICKABLE] ?? true) as boolean; + const clickableProperty: MapboxClickableProperty = { style: [clickableState] }; + this.updateInjectableProperty(Interactions.CLICKABLE, clickableProperty) + + // Inject hover state if indicated + if (this.definition[Interactions.HOVER]) { + const hoverJsonArray: JsonArray = this.definition[Interactions.HOVER] as JsonArray; + if (hoverJsonArray.length !== 2) { + throw new Error(`Invalid hover property detected for layer: ${this.id}. The hover property should have two values.`); + } + const hoverProperty: MapboxHoverProperty = { style: ["case", ["==", ["get", "iri"], "[HOVERED-IRI]"], Number(hoverJsonArray[0]), Number(hoverJsonArray[1])] }; + this.updateInjectableProperty(Interactions.HOVER, hoverProperty) } + + if (this.definition.isAHighlightLayer) { + this.isAHighlightLayer = this.definition.isAHighlightLayer as boolean; + this.highlightFeatureId = this.definition.highlightFeatureId as string; + } + + if (this.definition.hasHighlight) { + this.hasHighlight = this.definition.hasHighlight as boolean; + } + } + + /** + * Retrieves the injectable property associated with the specific interaction type. + * + * @param {string} interactionType The type of interaction ("hover") + */ + public getInjectableProperty(interactionType: string): InjectableProperty { + return this.injectableProperties[interactionType]; + } + + /** + * Check if the specific interaction type exists in this layer. + * + * @param {string} interactionType The type of interaction ("hover") + */ + public hasInjectableProperty(interactionType: string): boolean { + return interactionType in this.injectableProperties; + } + + /** + * Update an injectable property for a specific interaction type and map type. + * + * @param {string} interactionType The type of interaction ("hover") + * @param {InjectableProperty} property An injectable property to add + */ + public updateInjectableProperty(interactionType: string, property: InjectableProperty): void { + this.injectableProperties[interactionType] = property; + } } \ No newline at end of file diff --git a/code/src/io/data/data-parser.ts b/code/src/io/data/data-parser.ts index 53fda54a1..907f77541 100644 --- a/code/src/io/data/data-parser.ts +++ b/code/src/io/data/data-parser.ts @@ -10,276 +10,297 @@ import { MapboxDataLayer } from './mapbox/mapbox-data-layer'; * Handles parsing of raw JSON data into instances of the data classes. */ export class DataParser { - private readonly mapType: string; - // Data store to populate with parsed objects - private readonly dataStore: DataStore; + private readonly mapType: string; + // Data store to populate with parsed objects + private readonly dataStore: DataStore; - /** - * Initialise a new DataParser instance. - */ - constructor(mapType: string) { - this.dataStore = new DataStore(); - this.mapType = mapType; - } + /** + * Initialise a new DataParser instance. + */ + constructor(mapType: string) { + this.dataStore = new DataStore(); + this.mapType = mapType; + } - /** - * Parse the input raw JSON into objects and store within the - * current DataStore instance. - * - * @param rawJson JSON of data.json file. - */ - public loadData(rawJson: JsonObject): DataStore { - if (Array.isArray(rawJson)) { - rawJson.map((dataset) => { - this.recurse(dataset, null, null, 0); - }) - } else { - this.recurse(rawJson, null, null, 0); - } - console.info("Data definition loading complete."); - return this.dataStore; + /** + * Parse the input raw JSON into objects and store within the + * current DataStore instance. + * + * @param rawJson JSON of data.json file. + */ + public loadData(rawJson: JsonObject): DataStore { + if (Array.isArray(rawJson)) { + rawJson.map((dataset) => { + this.recurse(dataset, null, null, 0); + }) + } else { + this.recurse(rawJson, null, null, 0); } + console.info("Data definition loading complete."); + return this.dataStore; + } - /** - * - * @param current current JSON node. - * @param parentGroup parent DataGroup (if known). - * @param stack current stack URL. - * @param depth depth in JSON tree. - */ - private recurse(current: JsonObject, parentGroup: DataGroup, stack: string, depth: number) { - if (!current["name"]) { - throw new Error("Cannot parse a DataGroup that has no name!") - } + /** + * + * @param current current JSON node. + * @param parentGroup parent DataGroup (if known). + * @param stack current stack URL. + * @param depth depth in JSON tree. + */ + private recurse(current: JsonObject, parentGroup: DataGroup, stack: string, depth: number) { + if (!current["name"]) { + throw new Error("Cannot parse a DataGroup that has no name!") + } - // Retrieve the current stack for this group - let currentStack: string; - // If there is a stack property in the data.json, ensure that it is a string - if (current["stack"]) { - if (typeof current["stack"] === "string") { - currentStack = current["stack"]; - } else { - console.error("Unexpected type for 'stack' property"); - throw new Error("Unexpected type for 'stack' property") - } - // If there is no stack property, assume that it is inherited from the parent group. Else, leave as undefined - } else { - currentStack = (parentGroup != null) ? parentGroup.stackEndpoint : "undefined"; - } + // Retrieve the current stack for this group + let currentStack: string; + // If there is a stack property in the data.json, ensure that it is a string + if (current["stack"]) { + if (typeof current["stack"] === "string") { + currentStack = current["stack"]; + } else { + console.error("Unexpected type for 'stack' property"); + throw new Error("Unexpected type for 'stack' property") + } + // If there is no stack property, assume that it is inherited from the parent group. Else, leave as undefined + } else { + currentStack = (parentGroup != null) ? parentGroup.stackEndpoint : "undefined"; + } - // Initialise data group - const groupName: string = current["name"] as string; - let isGroupExpanded: boolean = true; - // Keep the check in this order - if (Object.hasOwn(current, "expanded")) { - isGroupExpanded = parentGroup?.isExpanded ? current["expanded"] as boolean : false; - } else if (parentGroup) { - isGroupExpanded = parentGroup.isExpanded; - } - const groupID: string = (parentGroup != null) ? (parentGroup.id + "." + depth) : depth.toString(); - const dataGroup: DataGroup = new DataGroup(groupName, groupID, currentStack, isGroupExpanded); + // Initialise data group + const groupName: string = current["name"] as string; + let isGroupExpanded: boolean = true; + // Keep the check in this order + if (Object.hasOwn(current, "expanded")) { + isGroupExpanded = parentGroup?.isExpanded ? current["expanded"] as boolean : false; + } else if (parentGroup) { + isGroupExpanded = parentGroup.isExpanded; + } + const groupID: string = (parentGroup != null) ? (parentGroup.id + "." + depth) : depth.toString(); + const dataGroup: DataGroup = new DataGroup(groupName, groupID, currentStack, isGroupExpanded); - // Store parent (if not root) - if (parentGroup === null || parentGroup === undefined) { - this.dataStore.addGroup(dataGroup); - } else { - dataGroup.parentGroup = parentGroup; - parentGroup.subGroups.push(dataGroup); - } + // Store parent (if not root) + if (parentGroup === null || parentGroup === undefined) { + this.dataStore.addGroup(dataGroup); + } else { + dataGroup.parentGroup = parentGroup; + parentGroup.subGroups.push(dataGroup); + } - // Parse sources and layers (if present) - if (current["sources"]) { - this.parseLayerSources(current["sources"] as JsonArray, dataGroup); - } - if (current["layers"]) { - const currentLayerArray: JsonArray = current["layers"] as JsonArray; - if (currentLayerArray.some(layer => Object.hasOwn(layer, "grouping"))) { - this.verifyGroupings(currentLayerArray); - dataGroup.layerGroupings = this.reorderGroupings(currentLayerArray); - } - this.parseDataLayers(current["layers"] as JsonArray, dataGroup); - } + // Parse sources and layers (if present) + if (current["sources"]) { + this.parseLayerSources(current["sources"] as JsonArray, dataGroup); + } + if (current["layers"]) { + const currentLayerArray: JsonArray = current["layers"] as JsonArray; + if (currentLayerArray.some(layer => Object.hasOwn(layer, "grouping"))) { + this.verifyGroupings(currentLayerArray); + dataGroup.layerGroupings = this.reorderGroupings(currentLayerArray); + } + this.parseDataLayers(current["layers"] as JsonArray, dataGroup); + } - // Add tree icon (if set) - if (current["tree-icon"]) { - dataGroup.treeIcon = current["tree-icon"] as string; - } - // Add search resource identifier (if set) - if (current["search"]) { - dataGroup.search = current["search"] as string; - } - // Recurse into sub groups (if present) - if (current["groups"]) { - const groupArray = current["groups"] as JsonArray; + // Add tree icon (if set) + if (current["tree-icon"]) { + dataGroup.treeIcon = current["tree-icon"] as string; + } + // Add search resource identifier (if set) + if (current["search"]) { + dataGroup.search = current["search"] as string; + } + // Recurse into sub groups (if present) + if (current["groups"]) { + const groupArray = current["groups"] as JsonArray; - for (let i = 0; i < groupArray.length; i++) { - const subNode = groupArray[i]; - this.recurse(subNode, dataGroup, stack, i); - } - } + for (let i = 0; i < groupArray.length; i++) { + const subNode = groupArray[i]; + this.recurse(subNode, dataGroup, stack, i); + } } + } - /** - * Parses the incoming JSON array into source objects and adds them - * to the input data group. - * - * @param sourceArray array of JSON source objects. - * @param dataGroup group to add sources to., - */ - private parseLayerSources(sourceArray: JsonArray, dataGroup: DataGroup) { - for (const element of sourceArray) { + /** + * Parses the incoming JSON array into source objects and adds them + * to the input data group. + * + * @param sourceArray array of JSON source objects. + * @param dataGroup group to add sources to., + */ + private parseLayerSources(sourceArray: JsonArray, dataGroup: DataGroup) { + for (const element of sourceArray) { - const sourceID = dataGroup.id + "." + (element["id"] as string); - const source = new LayerSource( - sourceID, - element["type"] as string, - dataGroup.stackEndpoint, - element - ); + const sourceID = dataGroup.id + "." + (element["id"] as string); + const source = new LayerSource( + sourceID, + element["type"] as string, + dataGroup.stackEndpoint, + element + ); - dataGroup.layerSources.push(source); - } + dataGroup.layerSources.push(source); } + } + + /** + * Parses the incoming JSON array into layer objects and adds them + * to the input data group. + * + * @param layerArray array of JSON layer objects. + * @param dataGroup group to add layer to. + */ + private parseDataLayers(layerArray: JsonArray, dataGroup: DataGroup) { + // if highlight is specified, the layer is duplicated with the highlight properties + // the highglight layer is not shown in the tree and will only be shown when a feature is selected on the map + const layerArrayWithHighlightDuplicate = layerArray.flatMap(item => + item.highlight + ? [ + { ...item, hasHighlight: true }, + { + ...item, + ...(item.highlight as object), + id: item.id + '-highlight', + name: item.name + '-highlight', + isAHighlightLayer: true + } + ] + : [item] + ); + for (const element of layerArrayWithHighlightDuplicate) { + const elementID = element["id"] as string; - /** - * Parses the incoming JSON array into layer objects and adds them - * to the input data group. - * - * @param layerArray array of JSON layer objects. - * @param dataGroup group to add layer to. - */ - private parseDataLayers(layerArray: JsonArray, dataGroup: DataGroup) { - for (const element of layerArray) { - const elementID = element["id"] as string; + // Get matching source, ensure exists + const originalSourceID = (element["source"] as string); + const uniqueSourceID = this.recurseUpForSource(dataGroup, originalSourceID); - // Get matching source, ensure exists - const originalSourceID = (element["source"] as string); - const uniqueSourceID = this.recurseUpForSource(dataGroup, originalSourceID); + const sourceObj = dataGroup.getFirstSourceWithID(uniqueSourceID); + if (sourceObj == null) { + console.error("Layer with ID '" + elementID + "' references a source that is not defined, will skip it!"); + continue; + } - const sourceObj = dataGroup.getFirstSourceWithID(uniqueSourceID); - if (sourceObj == null) { - console.error("Layer with ID '" + elementID + "' references a source that is not defined, will skip it!"); - continue; - } + // Create concrete class for data layer + let layer: DataLayer; + const layerID = dataGroup.id + "." + elementID; - // Create concrete class for data layer - let layer: DataLayer; - const layerID = dataGroup.id + "." + elementID; + if (element.isAHighlightLayer) { + // ensures that highlight layer does not show up on first load + element.layout = { ...element.layout as object, visibility: "none" }; + } - switch (this.mapType.toLowerCase()) { - case "mapbox": - layer = new MapboxDataLayer( - layerID, - dataGroup.isExpanded, - sourceObj, - element, - ); - // When there is a default grouping and this is not the current grouping, the layer should start off invisible - if (layer.grouping && String(layer.grouping) !== dataGroup.layerGroupings[0]) { layer.cachedVisibility = false; } - break; + switch (this.mapType.toLowerCase()) { + case "mapbox": + layer = new MapboxDataLayer( + layerID, + dataGroup.isExpanded, + sourceObj, + element, + ); + // When there is a default grouping and this is not the current grouping, the layer should start off invisible + if (layer.grouping && String(layer.grouping) !== dataGroup.layerGroupings[0]) { layer.cachedVisibility = false; } + break; - case "cesium": - throw new Error("Not yet implemented."); + case "cesium": + throw new Error("Not yet implemented."); - default: - throw new Error("Unknown map provider type, stopping execution."); - } + default: + throw new Error("Unknown map provider type, stopping execution."); + } - // Add order number (if set) - if (element["order"] != null) { - layer.order = element["order"] as number; - } + // Add order number (if set) + if (element["order"] != null) { + layer.order = element["order"] as number; + } - // Cache visibility & interaction level - this.setVisibility(element, layer); - this.setInteractions(element, layer); + // Cache visibility & interaction level + this.setVisibility(element, layer); + this.setInteractions(element, layer); - // Store the layer - dataGroup.dataLayers.push(layer); - } + // Store the layer + dataGroup.dataLayers.push(layer); } + } - /** Verify if the groupings are correctly set within the data.json. - * - * @param {JsonArray} layerArray input array for verification. - */ - private verifyGroupings(layerArray: JsonArray): void { - // Ensure the grouping field is attached to all layers - if (!layerArray.every(layer => Object.hasOwn(layer, "grouping"))) { - throw new Error("Groupings detected for some layers. Please ensure all layers have a grouping field."); - } + /** Verify if the groupings are correctly set within the data.json. + * + * @param {JsonArray} layerArray input array for verification. + */ + private verifyGroupings(layerArray: JsonArray): void { + // Ensure the grouping field is attached to all layers + if (!layerArray.every(layer => Object.hasOwn(layer, "grouping"))) { + throw new Error("Groupings detected for some layers. Please ensure all layers have a grouping field."); } + } - /** Reorder the layers so that default grouping always appear first. - * - * @param {JsonArray} layerArray input array for reordering. - */ - private reorderGroupings(layerArray: JsonArray): string[] { - const uniqueGroupings: string[] = [...new Set(layerArray.map(layer => layer.grouping as string))]; - // Rearrange if 'Default' grouping exists - const defaultIndex = uniqueGroupings.findIndex(grouping => grouping.toLowerCase().trim() === "default"); - if (defaultIndex !== -1) { - // Remove the default string from the array - const defaultString: string = uniqueGroupings.splice(defaultIndex, 1)[0]; - // Add the default string back at the beginning - uniqueGroupings.unshift(defaultString); - } - return uniqueGroupings; + /** Reorder the layers so that default grouping always appear first. + * + * @param {JsonArray} layerArray input array for reordering. + */ + private reorderGroupings(layerArray: JsonArray): string[] { + const uniqueGroupings: string[] = [...new Set(layerArray.map(layer => layer.grouping as string))]; + // Rearrange if 'Default' grouping exists + const defaultIndex = uniqueGroupings.findIndex(grouping => grouping.toLowerCase().trim() === "default"); + if (defaultIndex !== -1) { + // Remove the default string from the array + const defaultString: string = uniqueGroupings.splice(defaultIndex, 1)[0]; + // Add the default string back at the beginning + uniqueGroupings.unshift(defaultString); } + return uniqueGroupings; + } - /** - * - * @param element - * @param layer - */ - private setVisibility(element: JsonObject, layer: DataLayer) { - // If the data group is visible, we then verify if the layer should be visible - if (layer.isGroupExpanded) { - const layoutObj = element["layout"] as JsonObject; - if (layoutObj?.["visibility"] != null) { - layer.cachedVisibility = (layoutObj["visibility"] == "visible"); - } else if (element["visibility"] != null) { - // Support older format of this property - layer.cachedVisibility = (element["visibility"] == "visible"); - } - } + /** + * + * @param element + * @param layer + */ + private setVisibility(element: JsonObject, layer: DataLayer) { + // If the data group is visible, we then verify if the layer should be visible + if (layer.isGroupExpanded) { + const layoutObj = element["layout"] as JsonObject; + if (layoutObj?.["visibility"] != null) { + layer.cachedVisibility = (layoutObj["visibility"] == "visible"); + } else if (element["visibility"] != null) { + // Support older format of this property + layer.cachedVisibility = (element["visibility"] == "visible"); + } } + } - /** - * - * @param element - * @param layer - */ - private setInteractions(element: JsonObject, layer: DataLayer) { - if (element["interactions"] != null) { - layer.interactions = element["interactions"] as string; - } else if (element["clickable"] != null) { - // Support older format of this property - layer.interactions = (element["clickable"]) ? "all" : "none"; - } + /** + * + * @param element + * @param layer + */ + private setInteractions(element: JsonObject, layer: DataLayer) { + if (element["interactions"] != null) { + layer.interactions = element["interactions"] as string; + } else if (element["clickable"] != null) { + // Support older format of this property + layer.interactions = (element["clickable"]) ? "all" : "none"; } + } - /** - * - * @param dataGroup - * @param sourceID original source ID (before prefix) - * @returns - */ - private recurseUpForSource(dataGroup: DataGroup, sourceID: string): string { - for (const source of dataGroup.layerSources) { - const sourceDef = source.definition; - if ((sourceDef["id"] as string) === sourceID) return source.id; - } - return this.recurseUpForSource(dataGroup.parentGroup, sourceID); + /** + * + * @param dataGroup + * @param sourceID original source ID (before prefix) + * @returns + */ + private recurseUpForSource(dataGroup: DataGroup, sourceID: string): string { + for (const source of dataGroup.layerSources) { + const sourceDef = source.definition; + if ((sourceDef["id"] as string) === sourceID) return source.id; } + return this.recurseUpForSource(dataGroup.parentGroup, sourceID); + } - public static handleDimensions(element: JsonObject, scenarioDimensionsData: ScenarioDimensionsData, value: number): JsonObject { - let stringified = JSON.stringify(element); - for (const dimension of Object.keys(scenarioDimensionsData)) { - stringified = stringified.replaceAll(`{` + dimension + `}`, value.toString()) - } - return JSON.parse(stringified); + public static handleDimensions(element: JsonObject, scenarioDimensionsData: ScenarioDimensionsData, value: number): JsonObject { + let stringified = JSON.stringify(element); + for (const dimension of Object.keys(scenarioDimensionsData)) { + stringified = stringified.replaceAll(`{` + dimension + `}`, value.toString()) } + return JSON.parse(stringified); + } } // End of class. \ No newline at end of file diff --git a/code/src/types/map-layer.ts b/code/src/types/map-layer.ts index e1966aed7..ab732fca5 100644 --- a/code/src/types/map-layer.ts +++ b/code/src/types/map-layer.ts @@ -13,8 +13,10 @@ export type MapLayerGroup = { export type MapLayer = { name: string; address: string; - ids: string; + ids: string[]; icon?: string; grouping?: string; // Map layer grouping if available isVisible: boolean; // track visibility + isAHighlightLayer: boolean; // indicates whether this is a highlight layer, will not be rendered on the layer tree + highlightLayerIds: string[]; // the accompanying highlight layer }; \ No newline at end of file diff --git a/code/src/ui/interaction/dropdown/feature-selector.tsx b/code/src/ui/interaction/dropdown/feature-selector.tsx index 90cfe6478..eee614c18 100644 --- a/code/src/ui/interaction/dropdown/feature-selector.tsx +++ b/code/src/ui/interaction/dropdown/feature-selector.tsx @@ -6,11 +6,15 @@ import React from 'react'; import { MapFeaturePayload } from 'state/map-feature-slice'; import { Dictionary } from 'types/dictionary'; import GroupDropdownField from 'ui/interaction/dropdown/group-dropdown'; -import { setSelectedFeature } from 'utils/client-utils'; +import { setSelectedFeature, highlightFeature } from 'utils/client-utils'; import { useDictionary } from 'hooks/useDictionary'; +import { Map } from "mapbox-gl"; +import { DataStore } from 'io/data/data-store'; interface FeatureSelectorProps { features: MapFeaturePayload[]; + map: Map; + dataStore: DataStore; } /** @@ -24,7 +28,8 @@ export default function FeatureSelector(props: Readonly) { const handleSelectorChange = (event: React.ChangeEvent) => { const selectedFeature: MapFeaturePayload = props.features.find((feature) => feature.name === event.target.value); - setSelectedFeature(selectedFeature, dispatch); + setSelectedFeature(selectedFeature, props.map, dispatch); + highlightFeature(selectedFeature, props.map, props.dataStore); }; return ( diff --git a/code/src/ui/interaction/tree/floating-panel.tsx b/code/src/ui/interaction/tree/floating-panel.tsx index f81480ecd..866970313 100644 --- a/code/src/ui/interaction/tree/floating-panel.tsx +++ b/code/src/ui/interaction/tree/floating-panel.tsx @@ -170,6 +170,8 @@ export default function FloatingPanelContainer( setActiveTab: setActiveInfoTab, }} features={availableFeatures} + map={props.map} + dataStore={props.dataStore} />} diff --git a/code/src/ui/interaction/tree/info/info-tree.tsx b/code/src/ui/interaction/tree/info/info-tree.tsx index 1fd86a1a2..836dceea8 100644 --- a/code/src/ui/interaction/tree/info/info-tree.tsx +++ b/code/src/ui/interaction/tree/info/info-tree.tsx @@ -10,11 +10,13 @@ import { Dictionary } from 'types/dictionary'; import { TimeSeries } from 'types/timeseries'; import LoadingSpinner from 'ui/graphic/loader/spinner'; import FeatureSelector from 'ui/interaction/dropdown/feature-selector'; -import { setSelectedFeature } from 'utils/client-utils'; +import { setSelectedFeature, highlightFeature } from 'utils/client-utils'; import { useDictionary } from 'hooks/useDictionary'; import AttributeRoot from './attribute-root'; import InfoTabs from './info-tabs'; import TimeSeriesPanel from './time-series-panel'; +import { Map } from "mapbox-gl"; +import { DataStore } from 'io/data/data-store'; interface InfoTreeProps { attributes: AttributeGroup; @@ -27,6 +29,8 @@ interface InfoTreeProps { setActiveTab: React.Dispatch>; }; features: MapFeaturePayload[]; + map: Map; + dataStore: DataStore; } /** @@ -72,10 +76,11 @@ export default function InfoTree(props: Readonly) { // If there are multiple features clicked, activate feature selector to choose only one if (props.features.length > 1) { - return ; + return ; } else if (props.features.length === 1) { // When only feature is available, set its properties - setSelectedFeature(props.features[0], dispatch); + setSelectedFeature(props.features[0], props.map, dispatch); + highlightFeature(props.features[0], props.map, props.dataStore); } // If active tab is 0, render the Metadata Tree if (props.attributes && props.activeTab.index === 0) { diff --git a/code/src/ui/interaction/tree/layer/layer-tree-content.tsx b/code/src/ui/interaction/tree/layer/layer-tree-content.tsx index a09f942dc..e47ee60e8 100644 --- a/code/src/ui/interaction/tree/layer/layer-tree-content.tsx +++ b/code/src/ui/interaction/tree/layer/layer-tree-content.tsx @@ -31,7 +31,7 @@ interface LayerTreeEntryProps { layer: MapLayer; depth: number; currentGrouping: string; - handleLayerVisibility: (_layerIds: string, _isVisible: boolean) => void; + handleLayerVisibility: (_layer: MapLayer, _isVisible: boolean) => void; } /** @@ -90,9 +90,9 @@ export default function LayerTreeHeader(props: Readonly) { currentGroupingView === "" ? beforeVisibleState : layer.grouping === currentGroupingView - ? isExpanded - : true; - toggleMapLayerVisibility(layer.ids, visibleState); + ? isExpanded + : true; + toggleMapLayerVisibility(layer, visibleState); }); }; @@ -122,15 +122,21 @@ export default function LayerTreeHeader(props: Readonly) { * Currently visible layers will become hidden. * Currently hidden layers will become shown. */ - const toggleMapLayerVisibility = (layerIds: string, isVisible: boolean) => { + const toggleMapLayerVisibility = (layer: MapLayer, isVisible: boolean) => { // Split layer IDs in case there are multiple - layerIds.split(" ").forEach((id) => { + layer.ids.forEach((id) => { if (isVisible) { props.map?.setLayoutProperty(id, "visibility", "none"); } else { props.map?.setLayoutProperty(id, "visibility", "visible"); } }); + + layer.highlightLayerIds.forEach((id) => { + if (isVisible) { + props.map?.setLayoutProperty(id, "visibility", "none"); + } + }); }; /** A method that toggles the grouping visibility based on the currently selected view @@ -141,7 +147,7 @@ export default function LayerTreeHeader(props: Readonly) { // This state should be the inverse of the toggled state // If we want to switch off the layer, it should start as true const visibleState: boolean = layer.grouping !== currentView; - toggleMapLayerVisibility(layer.ids, visibleState); + toggleMapLayerVisibility(layer, visibleState); }); // Reorder the groupings so that the selected grouping is always first and this state is saved const selectedIndex = groupings.indexOf(currentView); @@ -164,7 +170,7 @@ export default function LayerTreeHeader(props: Readonly) { /** A method to open the search modal on click. */ const openSearchModal = () => { - const layerIds: string[] = group.layers.map((layer) => layer.ids); + const layerIds: string[] = group.layers.map((layer) => layer.ids).flat(); // Add filter layer IDs dispatch(setFilterLayerIds(layerIds)); // Reset filtered features state when opened @@ -225,10 +231,10 @@ export default function LayerTreeHeader(props: Readonly) { )} - {/* Conditionally show subgroups when expanded */} + {/* Conditionally show subgroups when expanded, highlight layers are hidden */} {isExpanded && (
- {group.layers.map((layer) => { + {group.layers.filter(layer => !layer.isAHighlightLayer).map((layer) => { if ( groupings.length === 0 || layer.grouping === currentGroupingView @@ -274,7 +280,7 @@ export default function LayerTreeHeader(props: Readonly) { */ function LayerTreeEntry(props: Readonly) { const layer: MapLayer = props.layer; - const firstLayerId: string = layer.ids.split(" ")[0]; + const firstLayerId: string = layer.ids[0]; // Size of left hand indentation const spacing: string = props.depth * 0.8 + "rem"; @@ -292,7 +298,7 @@ function LayerTreeEntry(props: Readonly) { */ const toggleLayerVisibility = () => { // Toggle visibility on the map based on current state - props.handleLayerVisibility(layer.ids, isVisible); + props.handleLayerVisibility(layer, isVisible); // Get current visibility state of the layer after any toggling setIsVisible( props.map?.getLayoutProperty(firstLayerId, "visibility") === "visible" @@ -344,4 +350,4 @@ function LayerTreeEntry(props: Readonly) {
); -} +} \ No newline at end of file diff --git a/code/src/ui/interaction/tree/layer/layer-tree.tsx b/code/src/ui/interaction/tree/layer/layer-tree.tsx index 64f1d5634..ac975d3ea 100644 --- a/code/src/ui/interaction/tree/layer/layer-tree.tsx +++ b/code/src/ui/interaction/tree/layer/layer-tree.tsx @@ -107,11 +107,13 @@ function recurseParseTreeStructure( const mapLayer: MapLayer = { name: key, address: dataGroup.name + "." + key, - ids: collectIDs(layers), + ids: layers.map(layer => layer.id), icon: getIcon(layers, icons), grouping: layers.find((layer) => layer.grouping !== undefined)?.grouping, isVisible: layers.find((layer) => layer.cachedVisibility !== null) ?.cachedVisibility, + isAHighlightLayer: layers.every(layer => layer.isAHighlightLayer), // this is fine, either all layers will be highlight layers or not + highlightLayerIds: layers.filter(layer => layer.hasHighlight).map(layer => layer.id + '-highlight') }; mapLayerGroup.layers.push(mapLayer); } @@ -134,19 +136,6 @@ function recurseParseTreeStructure( } } -/** - * - * @param layers - * @returns - */ -function collectIDs(layers: DataLayer[]): string { - const ids = []; - for (const layer of layers) { - ids.push(layer.id); - } - return ids.join(" "); -} - /** Retrieve the icon from the current layers. This method will prioritise line colors over the icon if available. * * @param {DataLayers[]} layers Layers within the grouped layers. diff --git a/code/src/ui/map/map-event-manager.ts b/code/src/ui/map/map-event-manager.ts index d279b72eb..e9584e44a 100644 --- a/code/src/ui/map/map-event-manager.ts +++ b/code/src/ui/map/map-event-manager.ts @@ -66,6 +66,7 @@ export default class MapEventManager { name: twaFeature.properties.name ?? (twaFeature.id !== undefined ? "Feature #" + twaFeature.id : "Feature"), stack: dataStore?.getStackEndpoint(feature.source), // Store the associated stack if available layer: dataStore?.getLayerWithID(feature.layer.id)?.name, // Store the layer's public-facing name + layerId: feature.layer.id, // used to highlight feature from the correct layer }; }); @@ -129,13 +130,13 @@ export default class MapEventManager { const map: mapboxgl.Map = this.map; dataStore?.getLayerList().map(layer => { if (layer.hasInjectableProperty(Interactions.HOVER)) { - const hoverProperty = layer.getInjectableProperty(Interactions.HOVER).style ; + const hoverProperty = layer.getInjectableProperty(Interactions.HOVER).style; // Updates the conditional paint property with the IRI of the currently hovering feature this.addEventListener({ type: "mousemove", target: this.map }, (event) => { const e = event as MapMouseEvent; const feature = map.queryRenderedFeatures(e.point)[0]; const twaFeature = feature as unknown as TWAFeature; - const prevIri: string = hoverProperty[1][2] as string; + const prevIri: string = hoverProperty[1][2] as string; if (twaFeature.properties?.iri != prevIri) { hoverProperty[1][2] = twaFeature.properties?.iri as string; } diff --git a/code/src/ui/map/mapbox/mapbox-layer-utils.ts b/code/src/ui/map/mapbox/mapbox-layer-utils.ts index 8ffb7eb98..d52e827db 100644 --- a/code/src/ui/map/mapbox/mapbox-layer-utils.ts +++ b/code/src/ui/map/mapbox/mapbox-layer-utils.ts @@ -15,11 +15,11 @@ import { getCurrentImageryOption } from 'ui/map/map-helper'; * @param {ImageryOptions} imageryOptions - The imagery settings for the map. */ export async function addAllLayers(map: Map, dataStore: DataStore, imageryOptions: ImageryOptions) { - const currentStyle = getCurrentImageryOption(imageryOptions); + const currentStyle = getCurrentImageryOption(imageryOptions); - const layerArray: DataLayer[] = dataStore?.getLayerList(); - layerArray?.forEach((layer) => addLayer(map, layer, currentStyle)); - console.info(`Added ${layerArray?.length} layers to the map object.`); + const layerArray: DataLayer[] = dataStore?.getLayerList(); + layerArray?.forEach((layer) => addLayer(map, layer, currentStyle)); + console.info(`Added ${layerArray?.length} layers to the map object.`); } /** @@ -30,98 +30,102 @@ export async function addAllLayers(map: Map, dataStore: DataStore, imageryOption * @param {ImageryOption} currentStyle - The current imagery style. */ export function addLayer(map: Map, layer: DataLayer, currentStyle: ImageryOption) { - const collision = map?.getLayer(layer.id); + const collision = map?.getLayer(layer.id); - if (collision != null) { - console.warn("Attempting to add a layer that's already on map: '" + layer.id + "'."); - return; - } + if (collision != null) { + console.warn("Attempting to add a layer that's already on map: '" + layer.id + "'."); + return; + } - // Clone the original layer definition and adjust as needed - const options: JsonObject = { ...layer.definition }; - options["id"] = layer.id; - options["source"] = layer.source.id; - // If there is a layout option, we must set visibility separately to prevent overriding the other suboptions - if (options.layout) { - const layoutOptions: JsonObject = options.layout as JsonObject; - layoutOptions.visibility = layer.isGroupExpanded && layer.cachedVisibility ? "visible" : "none"; - } else { - options.layout = { - visibility: layer.isGroupExpanded && layer.cachedVisibility ? "visible" : "none" - }; - } + // Clone the original layer definition and adjust as needed + const options: JsonObject = { ...layer.definition }; + options["id"] = layer.id; + options["source"] = layer.source.id; + // If there is a layout option, we must set visibility separately to prevent overriding the other suboptions + if (options.layout) { + const layoutOptions: JsonObject = options.layout as JsonObject; + layoutOptions.visibility = layer.isGroupExpanded && layer.cachedVisibility ? "visible" : "none"; + } else { + options.layout = { + visibility: layer.isGroupExpanded && layer.cachedVisibility ? "visible" : "none" + }; + } - if (layer.getInjectableProperty(Interactions.HOVER)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (options.paint as { [key: string]: any })["fill-opacity"] = layer.getInjectableProperty(Interactions.HOVER).style; - delete options["hover"]; - } + if (layer.getInjectableProperty(Interactions.HOVER)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (options.paint as { [key: string]: any })["fill-opacity"] = layer.getInjectableProperty(Interactions.HOVER).style; + delete options["hover"]; + } - // Remove properties not expected by Mapbox - delete options["interactions"]; - delete options["clickable"]; - delete options["treeable"]; - delete options["name"]; - delete options["order"]; - delete options["grouping"]; - delete options["live"]; + // Remove properties not expected by Mapbox + delete options["interactions"]; + delete options["clickable"]; + delete options["treeable"]; + delete options["name"]; + delete options["order"]; + delete options["grouping"]; + delete options["live"]; + delete options["highlight"]; + delete options["highlightFeatureId"] + delete options["isAHighlightLayer"] + delete options["hasHighlight"] - // Add attributions if missing - if (!options["metadata"]) { - options["metadata"] = { - attribution: "CMCL" - } + // Add attributions if missing + if (!options["metadata"]) { + options["metadata"] = { + attribution: "CMCL" } + } - // Add slot setting if using v3 style - const isStandard = (currentStyle.url.includes("standard") || currentStyle.time != null); - if (isStandard && options["slot"] == null) { - options["slot"] = "top"; - } - // Have to cast to type specific object to meet Mapbox's API - let mapboxObj: LayerSpecification; - const layerType = layer.definition["type"]; + // Add slot setting if using v3 style + const isStandard = (currentStyle.url.includes("standard") || currentStyle.time != null); + if (isStandard && options["slot"] == null) { + options["slot"] = "top"; + } + // Have to cast to type specific object to meet Mapbox's API + let mapboxObj: LayerSpecification; + const layerType = layer.definition["type"]; - let paintObj = options["paint"] as JsonObject; - if (paintObj == null) { - paintObj = {}; - options["paint"] = paintObj; - } + let paintObj = options["paint"] as JsonObject; + if (paintObj == null) { + paintObj = {}; + options["paint"] = paintObj; + } - switch (layerType as string) { - case "background": - if (isStandard) paintObj["background-emissive-strength"] = 1.0; - mapboxObj = ((options as unknown) as BackgroundLayerSpecification); - break; - case "circle": - if (isStandard) paintObj["circle-emissive-strength"] = 1.0; - mapboxObj = ((options as unknown) as CircleLayerSpecification); - break; - case "fill-extrusion": - if (isStandard) paintObj["fill-emissive-strength"] = 1.0; - mapboxObj = ((options as unknown) as FillExtrusionLayerSpecification); - break; - case "fill": - if (isStandard) paintObj["fill-emissive-strength"] = 1.0; - mapboxObj = ((options as unknown) as FillLayerSpecification); - break; - case "heatmap": - mapboxObj = ((options as unknown) as HeatmapLayerSpecification); - break; - case "line": - if (isStandard) paintObj["line-emissive-strength"] = 1.0; - mapboxObj = ((options as unknown) as LineLayerSpecification); - break; - case "raster": - mapboxObj = ((options as unknown) as RasterLayerSpecification); - break; - case "symbol": - if (isStandard) paintObj["icon-emissive-strength"] = 1.0; - mapboxObj = ((options as unknown) as SymbolLayerSpecification); - break; - } + switch (layerType as string) { + case "background": + if (isStandard) paintObj["background-emissive-strength"] = 1.0; + mapboxObj = ((options as unknown) as BackgroundLayerSpecification); + break; + case "circle": + if (isStandard) paintObj["circle-emissive-strength"] = 1.0; + mapboxObj = ((options as unknown) as CircleLayerSpecification); + break; + case "fill-extrusion": + if (isStandard) paintObj["fill-emissive-strength"] = 1.0; + mapboxObj = ((options as unknown) as FillExtrusionLayerSpecification); + break; + case "fill": + if (isStandard) paintObj["fill-emissive-strength"] = 1.0; + mapboxObj = ((options as unknown) as FillLayerSpecification); + break; + case "heatmap": + mapboxObj = ((options as unknown) as HeatmapLayerSpecification); + break; + case "line": + if (isStandard) paintObj["line-emissive-strength"] = 1.0; + mapboxObj = ((options as unknown) as LineLayerSpecification); + break; + case "raster": + mapboxObj = ((options as unknown) as RasterLayerSpecification); + break; + case "symbol": + if (isStandard) paintObj["icon-emissive-strength"] = 1.0; + mapboxObj = ((options as unknown) as SymbolLayerSpecification); + break; + } - // Add to the map - map?.addLayer(mapboxObj); - console.info("Pushed data layer to map '" + layer.id + "'."); + // Add to the map + map?.addLayer(mapboxObj); + console.info("Pushed data layer to map '" + layer.id + "'."); } \ No newline at end of file diff --git a/code/src/utils/client-utils.ts b/code/src/utils/client-utils.ts index aeacc6579..d3ac5a332 100644 --- a/code/src/utils/client-utils.ts +++ b/code/src/utils/client-utils.ts @@ -21,6 +21,8 @@ import { } from "types/form"; import { JsonObject } from "types/json"; import { ToastConfig, ToastType } from "types/toast"; +import { Map } from "mapbox-gl"; +import { DataLayer } from 'io/data/data-layer'; /** * Open full screen mode. @@ -64,6 +66,7 @@ export function parseMapDataSettings( */ export function setSelectedFeature( selectedFeature: MapFeaturePayload, + map: Map, dispatch: Dispatch ): void { if (selectedFeature) { @@ -83,6 +86,24 @@ export function setSelectedFeature( } } +/** + * Check if an accompanying highlight layer exists, if it exists, make it visible and only show the selected feature + * @param selectedFeature + * @param map + * @param dataStore + */ +export function highlightFeature(selectedFeature: MapFeaturePayload, map: Map, dataStore: DataStore) { + + const layerArray: DataLayer[] = dataStore?.getLayerList(); + const layerContainingFeature = layerArray.find(layer => layer.id === selectedFeature.layerId); + + if (layerContainingFeature.hasHighlight) { + const highlightLayer = layerArray.find(layer => layer.id === selectedFeature.layerId + '-highlight'); + map.setFilter(highlightLayer.id, ['in', highlightLayer.highlightFeatureId, selectedFeature[highlightLayer.highlightFeatureId]]); + map.setLayoutProperty(highlightLayer.id, "visibility", "visible"); + } +} + /** * Capitalises the words. * diff --git a/doc/config.md b/doc/config.md index 0250f5833..d7e111b32 100644 --- a/doc/config.md +++ b/doc/config.md @@ -367,12 +367,17 @@ As with sources, definitions of layers vary depending on the chosen mapping prov - `clickable` (optional): Enables the layer to be clickable. Set to true by default. - `hovering` (optional): Creates a highlight effect when hovering over the layer's features. This parameter is an array of two numbers indicating the opacity for the highlighted and non-highlighted states respectively. - `isLive` (optional): If set to true, layer will regularly update and repaint. Useful for live data +- `highlight` (optional): This is to highlight a feature when clicked, a compulsory property is `highlightFeatureId`, this is the column containing the unique ID of the feature in the layer. When activated, a layer hidden from the user is created with the properties specified in `highlight` (excluding `highlightFeatureId`). Please see example below, where the paint properties of the highlight layer is specified. ```json { "id": "example-mapbox-layer", "name": "My Example Data", - "source": "example-mapbox-source" + "source": "example-mapbox-source", + "highlight": { + "highlightFeatureId": "ogc_fid", + "paint": {"circle-color": "red"} + } } ```