diff --git a/elements/layercontrol/src/components/layer-config.js b/elements/layercontrol/src/components/layer-config.js index cbe03f632..13c01f944 100644 --- a/elements/layercontrol/src/components/layer-config.js +++ b/elements/layercontrol/src/components/layer-config.js @@ -5,6 +5,13 @@ import { dataChangeMethod, applyUpdatedStyles } from "../methods/layer-config"; import { when } from "lit/directives/when.js"; import _throttle from "lodash.throttle"; import "./layer-legend"; +/** + * @typedef {Partial & { + * boundTo?:{key: string;value: string|number|boolean} + * domainProperties: string[] + * }} layerConfigLegend + **/ + /** * `EOxLayerControlLayerConfig` is a component that handles configuration options for layers using eox-jsonform. * It allows users to input data, modify layer settings, and update the UI based on those settings. @@ -74,12 +81,9 @@ export class EOxLayerControlLayerConfig extends LitElement { * @type {{ * schema: Record; * element: string; - * type?:"tileUrl"|"style"; - * style?:import("ol/layer/WebGLTile").Style - * legend?: Partial & { - * range:string[]; - * domainProperties:string[] - * } + * type?: "tileUrl" | "style"; + * style?: import("ol/layer/WebGLTile").Style + * legend?: layerConfigLegend | layerConfigLegend[] * }} */ this.layerConfig = null; @@ -131,6 +135,15 @@ export class EOxLayerControlLayerConfig extends LitElement { this, ); } + this.dispatchEvent( + new CustomEvent("layerConfig:change", { + bubbles: true, + detail: { + jsonformValue: e.detail, + layer: this.layer, + }, + }), + ); this.requestUpdate(); } diff --git a/elements/layercontrol/src/components/layer-legend.js b/elements/layercontrol/src/components/layer-legend.js index a51891197..7fbb86e14 100644 --- a/elements/layercontrol/src/components/layer-legend.js +++ b/elements/layercontrol/src/components/layer-legend.js @@ -1,7 +1,6 @@ -import { LitElement, html, css } from "lit"; +import { LitElement, html, css, nothing } from "lit"; import { when } from "lit/directives/when.js"; import { ifDefined } from "lit/directives/if-defined.js"; - /** * Legend configurations * @@ -50,13 +49,6 @@ export class EOxLayerControlLayerLegend extends LitElement { */ this.noShadow = false; - /** - * Legend configurations - * @type {LegendConfig} - * @see {@link https://clhenrick.github.io/color-legend-element/} - */ - this.layerLegend = null; - /** * The native OL layer * @@ -65,6 +57,38 @@ export class EOxLayerControlLayerLegend extends LitElement { */ this.layer = null; } + /** @type {(LegendConfig & {id:string})[]}} */ + #layerLegend = []; + + get layerLegend() { + if (this.#layerLegend) { + return this.#layerLegend.length > 1 + ? this.#layerLegend + : this.#layerLegend[0]; + } else { + return null; + } + } + /** + * Legend configurations + * @param {LegendConfig | LegendConfig[]} value + * @see {@link https://clhenrick.github.io/color-legend-element/} + **/ + set layerLegend(value) { + if (Array.isArray(value)) { + this.#layerLegend = value.map((config, idx) => ({ + id: (this.layer?.get("id") ?? "") + idx, + ...config, + })); + } else { + this.#layerLegend = [ + { + id: (this.layer?.get("id") ?? "") + 0, + ...value, + }, + ]; + } + } /** * Overrides createRenderRoot to handle shadow DOM creation based on the noShadow property. @@ -74,19 +98,21 @@ export class EOxLayerControlLayerLegend extends LitElement { } firstUpdated() { - // if the width is explicitly set, don't auto-update - if (!this.layerLegend.width) { + if (this.layerLegend) { new ResizeObserver(() => { - if (this.offsetWidth !== this.layerLegend.width) { - this.layerLegend.width = this.offsetWidth; - this.requestUpdate(); - } + this.#layerLegend = this.#layerLegend?.map((config) => { + if (this.offsetWidth !== config.width) { + config.width = this.offsetWidth; + } + return { ...config }; + }); + this.requestUpdate(); }).observe(this.renderRoot.querySelector(".legend-container")); } } /** - * Renders a Time Control for datetime options of a layer. + * Renders color legends based on the layerLegend property. */ render() { if (!customElements.get("color-legend")) { @@ -105,21 +131,28 @@ export class EOxLayerControlLayerLegend extends LitElement { () => html`
- - + ${this.#layerLegend.map( + (legend, idx, configs) => html` + + + ${idx !== configs.length - 1 + ? html`
` + : nothing} + `, + )}
`, )} @@ -127,6 +160,9 @@ export class EOxLayerControlLayerLegend extends LitElement { } #styleBasic = css` + .separator { + margin: 0 0 24px 0; + } color-legend { --cle-background: transparent; --cle-font-family: inherit; diff --git a/elements/layercontrol/src/enums/index.js b/elements/layercontrol/src/enums/index.js index 5d3bc5b62..cb53a71e7 100644 --- a/elements/layercontrol/src/enums/index.js +++ b/elements/layercontrol/src/enums/index.js @@ -13,4 +13,5 @@ export { STORIES_LAYER_VESSEL_DENSITY_CARGO, STORIES_LAYER_CROPOMHUSC2, STORIES_LAYER_SEE, + STORIES_LAYER_POLARIS, } from "./stories"; diff --git a/elements/layercontrol/src/enums/stories/assets/polaris-style.json b/elements/layercontrol/src/enums/stories/assets/polaris-style.json new file mode 100644 index 000000000..3b18be503 --- /dev/null +++ b/elements/layercontrol/src/enums/stories/assets/polaris-style.json @@ -0,0 +1,135 @@ +{ + "variables": { + "ship_class": "nis", + "type_of_ice": "standard", + "type_of_visualisation": "polaris", + "combined_prop": "polaris_standard_pc_nis_rio" + }, + "fill-color": [ + "case", + ["==", ["var", "type_of_visualisation"], "WMO Concentration"], + [ + "match", + ["get", "wmo_concentration"], + "Ice Free", + [0, 100, 255, 1], + "Open Water (< 1/10 ice)", + [150, 200, 255, 1], + "Bergy Water", + [150, 200, 255, 1], + "1/10", + [140, 255, 159, 1], + "2/10", + [140, 255, 159, 1], + "3/10", + [140, 255, 159, 1], + "4/10", + [255, 255, 0, 1], + "5/10", + [255, 255, 0, 1], + "6/10", + [255, 255, 0, 1], + "7/10", + [255, 125, 7, 1], + "8/10", + [255, 125, 7, 1], + "9/10", + [255, 0, 0, 1], + "10/10", + [255, 0, 0, 1], + "9/10 to 10/10 ice, 9+/10", + [255, 0, 0, 1], + "Unknown/Undetermined", + [255, 255, 255, 1], + [255, 255, 255, 1] + ], + ["==", ["var", "type_of_visualisation"], "WMO Stage of Development"], + [ + "match", + ["get", "wmo_stage_of_development"], + "Ice Free", + [0, 100, 255, 1], + "Brash Ice", + [0, 0, 0, 0], + "No stage of development", + [0, 0, 0, 0], + "New Ice", + [240, 210, 250, 1], + "Nilas Ice Rind (<10 cm)", + [255, 100, 255, 1], + "Young Ice (10 to 30 cm)", + [0, 0, 0, 0], + "Grey Ice", + [135, 60, 215, 1], + "Grey-White Ice", + [220, 80, 235, 1], + "First Year Ice (>30 cm) or Brash Ice", + [255, 255, 0, 1], + "Thin First Year Ice (30 to 70 cm)", + [155, 210, 0, 1], + "Medium First Year Ice (70 to 120 cm)", + [0, 200, 20, 1], + "Thick First Year Ice (>120 cm)", + [0, 120, 0, 1], + "Old Ice", + [180, 100, 50, 1], + "Second-Year Ice", + [255, 120, 10, 1], + "Multi-Year Ice", + [200, 0, 0, 1], + "Bergy Water", + [255, 255, 255, 1], + "Unknown/Undetermined", + [255, 255, 255, 1], + [0, 0, 0, 0] + ], + ["==", ["var", "type_of_visualisation"], "polaris"], + [ + "case", + ["==", ["get", "polygon_type"], "Ice Free"], + [0, 100, 255], + ["==", ["get", ["var", "combined_prop"]], "RIO > 20: Normal Operation"], + [54, 122, 74, 1], + [ + "==", + ["get", ["var", "combined_prop"]], + ">= 10 RIO < 20: Normal Operation" + ], + [62, 150, 85, 1], + [ + "==", + ["get", ["var", "combined_prop"]], + ">= 0 RIO < 10: Normal Operation" + ], + [102, 188, 118, 1], + [ + "==", + ["get", ["var", "combined_prop"]], + ">= -10 RIO < 0: Operation subject to special consideration" + ], + [252, 251, 1, 1], + [ + "==", + ["get", ["var", "combined_prop"]], + ">= -20 RIO < -10: Operation subject to special consideration" + ], + [227, 108, 9, 1], + [ + "==", + ["get", ["var", "combined_prop"]], + "RIO < -20: Operation subject to special consideration" + ], + [188, 1, 6, 1], + [ + "==", + ["get", ["var", "combined_prop"]], + "<= -10 RIO < 0: Elevated operational risk" + ], + [252, 251, 1, 1], + [0, 0, 0, 1] + ], + [0, 0, 0, 0] + ], + "stroke-color": "black", + "stroke-width": 1 +} diff --git a/elements/layercontrol/src/enums/stories/index.js b/elements/layercontrol/src/enums/stories/index.js index c615c05cd..4b2033f5c 100644 --- a/elements/layercontrol/src/enums/stories/index.js +++ b/elements/layercontrol/src/enums/stories/index.js @@ -1,4 +1,5 @@ import FEATURE_COLLECTION_LAYER_CROPOMHUSC from "./assets/cropomhusc-feature-collection.json"; +import POLARIS_STYLE from "./assets/polaris-style.json"; const SENTINEL_HUB_URL = "https://services.sentinel-hub.com/ogc/wms/0635c213-17a1-48ee-aef7-9d1731695a54"; @@ -410,6 +411,164 @@ const STYLES_LAYER_SEE = { ], }; +export const STORIES_LAYER_POLARIS = { + type: "Vector", + source: { + type: "FlatGeoBuf", + url: "https://eox-gtif-public.s3.eu-central-1.amazonaws.com/EOX/polaris/202408131425_CapeFarewell_RIC-processed.fgb", + format: "GeoJSON", + }, + properties: { + id: "Polaris_algorithm_dmi_demo;:;2024-08-13T00:00:00Z;:;0", + title: "Polaris Results demo", + layerConfig: { + schema: { + type: "object", + title: "Data configuration", + properties: { + type_of_visualisation: { + title: "Type of Visualisation", + type: "string", + enum: [ + "polaris", + "WMO Concentration", + "WMO Stage of Development", + "AIRSS Ice Numeral Go/No Go", + ], + options: { + enum_titles: [ + "POLARIS", + "WMO Concentration", + "WMO Stage of Development", + "AIRSS Ice Numeral Go/No Go", + ], + }, + default: "polaris", + }, + ship_class: { + title: "Ship Class", + type: "string", + enum: [ + "pc_1", + "pc_2", + "pc_3", + "pc_4", + "pc_5", + "pc_6", + "pc_7", + "pc_ias", + "pc_ia", + "pc_ib", + "pc_ic", + "pc_nis", + ], + options: { + enum_titles: [ + "PC1", + "PC2", + "PC3", + "PC4", + "PC5", + "PC6", + "PC7", + "PC IA Super", + "PC IA", + "PC IB", + "PC IC", + "PC NIS", + ], + }, + default: "pc_nis", + }, + type_of_ice: { + title: "Type of Ice (decayed/standard)", + type: "string", + enum: ["standard", "decayed"], + default: "standard", + }, + combined_prop: { + type: "string", + template: "{{vis}}_{{ice}}_{{ship}}_rio", + options: { + hidden: true, + }, + watch: { + vis: "type_of_visualisation", + ice: "type_of_ice", + ship: "ship_class", + }, + }, + }, + }, + type: "style", + legend: [ + { + title: "Total concentration colour code standard", + range: [ + "#367a4a", + "#3e9655", + "#66bc76", + "#fcfb01", + "#e36c09", + "#bc0106", + "#fcfb01", + ], + domain: [ + "RIO > 20: Normal Operation", + ">= 10 RIO < 20: Normal Operation", + ">= 0 RIO < 10: Normal Operation", + ">= -10 RIO < 0: Operation subject to special consideration", + ">= -20 RIO < -10: Operation subject to special consideration", + "RIO < -20: Operation subject to special consideration", + "<= -10 RIO < 0: Elevated operational risk", + ], + scaleType: "categorical", + markType: "circle", + boundTo: { + key: "type_of_visualisation", + value: "polaris", + }, + }, + { + title: "WMO Concentration", + range: ["#8cff9f", "#ffff00", "#ff7d07", "#ff0000"], + domain: ["1 - 3", "4 - 6", "7 - 9", "10"], + scaleType: "categorical", + markType: "circle", + boundTo: { + key: "type_of_visualisation", + value: "WMO Concentration", + }, + }, + ], + style: POLARIS_STYLE, + layerLegend: { + title: "Total concentration colour code standard", + range: [ + "#367a4a", + "#3e9655", + "#66bc76", + "#fcfb01", + "#e36c09", + "#bc0106", + "#fcfb01", + ], + domain: [ + "RIO > 20: Normal Operation", + ">= 10 RIO < 20: Normal Operation", + ">= 0 RIO < 10: Normal Operation", + ">= -10 RIO < 0: Operation subject to special consideration", + ">= -20 RIO < -10: Operation subject to special consideration", + "RIO < -20: Operation subject to special consideration", + "<= -10 RIO < 0: Elevated operational risk", + ], + scaleType: "categorical", + markType: "circle", + }, + }, + }, +}; + export const STORIES_LAYER_SEE = { type: "WebGLTile", style: STYLES_LAYER_SEE, diff --git a/elements/layercontrol/src/helpers/get-legend-config.js b/elements/layercontrol/src/helpers/get-legend-config.js index 8fc19bde4..8eaa5aa35 100644 --- a/elements/layercontrol/src/helpers/get-legend-config.js +++ b/elements/layercontrol/src/helpers/get-legend-config.js @@ -6,20 +6,61 @@ import { flattenObject } from "../methods/layer-config"; * @param {Record} data */ const getLegendConfig = (legendConfig, data) => { - if (!legendConfig) return undefined; - if (!("domainProperties" in legendConfig) || "domain" in legendConfig) { - return { ...legendConfig }; + if (!legendConfig) { + return undefined; } - return Object.keys(legendConfig)?.reduce((legend, key) => { - if (key === "domainProperties") { - const flatData_ = flattenObject(data); - legend["domain"] = legendConfig[key].map((k) => flatData_[k]); - } else { - // @ts-expect-error TODO - legend[key] = legendConfig[key]; + + const flatData = flattenObject(data); + + /** + * legends config that will be shown + * @type {import("../components/layer-config").layerConfigLegend[]} */ + let activeLegends; + /** + * legends config that are available + * @type {import("../components/layer-config").layerConfigLegend[]} */ + let availableLegends; + + if (Array.isArray(legendConfig)) { + availableLegends = structuredClone(legendConfig); + } else { + availableLegends = [structuredClone(legendConfig)]; + } + + activeLegends = availableLegends.filter((legend) => { + // if the legend is not bound to a property, it will be shown + if (!("boundTo" in legend)) { + return true; } - return legend; - }, /** @type {import("../components/layer-legend").LegendConfig} */ ({})); + + const propName = legend.boundTo.key; + const expectedToMatch = legend.boundTo.value; + + // if the property is not in the data, the legend will be shown + return !(propName in flatData) || flatData[propName] == expectedToMatch; + }); + + // if no active legends are found, use all legends + if (!activeLegends.length) { + activeLegends = availableLegends; + } + + return /** @type {import("../components/layer-legend").LegendConfig[]} */ ( + activeLegends.map((activeLegend) => { + delete activeLegend.boundTo; + if (!("domainProperties" in activeLegend) || "domain" in activeLegend) { + return activeLegend; + } + return Object.keys(activeLegend)?.reduce((legend, key) => { + if (key === "domainProperties") { + legend["domain"] = activeLegend[key].map((k) => flatData[k]); + } else { + legend[key] = activeLegend[key]; + } + return legend; + }, /** @type {import("../components/layer-legend").LegendConfig} */ ({})); + }) + ); }; export default getLegendConfig; diff --git a/elements/layercontrol/src/main.js b/elements/layercontrol/src/main.js index a66020728..9ef52a273 100644 --- a/elements/layercontrol/src/main.js +++ b/elements/layercontrol/src/main.js @@ -194,6 +194,16 @@ export class EOxLayerControl extends LitElement { ); } + /** + * Dispatches jsonform updates from layer config to the layercontrol + * @param {CustomEvent} evt + */ + #handleLayerConfigChange(evt) { + this.dispatchEvent( + new CustomEvent("layerConfig:change", { detail: evt.detail }), + ); + } + render() { // Checks if there are any layers with the 'layerControlOptional' property set to true const layers = this.map?.getLayers().getArray(); @@ -235,6 +245,7 @@ export class EOxLayerControl extends LitElement { .toolsAsList=${this.toolsAsList} @changed=${this.#handleLayerControlLayerListChange} @datetime:updated=${this.#handleDatetimeUpdate} + @layerConfig:change=${this.#handleLayerConfigChange} > `, )} diff --git a/elements/layercontrol/src/methods/layer-config/apply-updated-styles.js b/elements/layercontrol/src/methods/layer-config/apply-updated-styles.js index d81f9fd56..f8082bd73 100644 --- a/elements/layercontrol/src/methods/layer-config/apply-updated-styles.js +++ b/elements/layercontrol/src/methods/layer-config/apply-updated-styles.js @@ -40,7 +40,7 @@ export default function (jsonformOutput, layer, layerConfig) { export const flattenObject = (obj) => { /** * the flattened object to be returned - * @type {Record} */ + * @type {Record} */ const flat = {}; // loop through the keys of the object for (const key in obj) { @@ -82,7 +82,6 @@ function updateVectorLayerStyle(styles) { for (const key in variables) { // ol styles expects numbers to be assigned as typeof number if (typeof variables[key] === "number") { - //@ts-expect-error ol styles expects number values to be assigned as type number rawStyle = rawStyle.replaceAll(`["var","${key}"]`, variables[key]); } else { // replace all styles variables set of the specific key with the variables value diff --git a/elements/layercontrol/stories/layer-config-styles.js b/elements/layercontrol/stories/layer-config-styles.js index 4aacf8825..f500b3d7c 100644 --- a/elements/layercontrol/stories/layer-config-styles.js +++ b/elements/layercontrol/stories/layer-config-styles.js @@ -5,6 +5,7 @@ import { STORIES_MAP_STYLE, STORIES_LAYER_SEE, STORIES_LAYER_TERRAIN_LIGHT, + STORIES_LAYER_POLARIS, } from "../src/enums"; // registering the projection of CROPOMHUSC2_VECTOR_CONFIG_STYLE_LAYER @@ -26,13 +27,14 @@ export default { >
diff --git a/elements/layercontrol/test/cases/general/check-layer-legend.js b/elements/layercontrol/test/cases/general/check-layer-legend.js index 53cfbfa5b..817370a73 100644 --- a/elements/layercontrol/test/cases/general/check-layer-legend.js +++ b/elements/layercontrol/test/cases/general/check-layer-legend.js @@ -3,18 +3,40 @@ import "color-legend-element"; export const checkLayerLegend = () => { const legendLayer = { properties: { - title: "Legend test", + id: "legend-test-1", + title: "Legend test 1", layerLegend: { title: "Legend title", range: ["green", "red"], domain: [0, 1], ticks: 4, tickValues: [0, 0.2, 0.4, 0.8], - width: 460, }, }, }; + const multiLegendsLayer = { + properties: { + id: "legend-test-2", + title: "Legend test 2", + layerLegend: [ + { + title: "Legend one", + range: ["green", "yellow"], + domain: [0, 1], + ticks: 4, + tickValues: [0, 0.2, 0.4, 0.8], + }, + { + title: "Legend two", + range: ["blue", "red"], + domain: [0, 1], + ticks: 3, + tickValues: [0.2, 0.4, 0.8], + }, + ], + }, + }; cy.get("eox-layercontrol").then(($el) => { const layerControlEl = $el[0]; layerControlEl.tools = ["legend"]; @@ -24,43 +46,44 @@ export const checkLayerLegend = () => { // Accessing the mock map element const mockMap = $el[0]; // Setting layers - mockMap.setLayers([legendLayer]); + mockMap.setLayers([legendLayer, multiLegendsLayer]); }); - + const legendConfigs = [ + legendLayer.properties.layerLegend, + multiLegendsLayer.properties.layerLegend, + ].flat(); cy.get("eox-layercontrol") .shadow() .within(() => { // check if the legend element exists - cy.get("color-legend") - .should("exist") - .shadow() - .within(() => { - // check if rendered legend title is equal to the configured title - cy.get("p.legend-title").then(($el) => { - expect($el[0].textContent).equal( - legendLayer.properties.layerLegend.title, - ); - }); + cy.get("color-legend").each(($el, idx) => { + cy.wrap($el) + .shadow() + .within(() => { + // check if rendered legend title is equal to the configured title + cy.get("p.legend-title").then(($el) => { + expect($el[0].textContent).to.equal(legendConfigs[idx].title); + }); - // check if the image (grandient) is rendered - cy.get("image").should("exist"); + // check if the image (grandient) is rendered + cy.get("image").should("exist"); - // check if the numbers of ticks rendered are equal to the configured ticks - cy.get("line").then(($el) => { - expect($el.length).equals(legendLayer.properties.layerLegend.ticks); - }); + // check if the numbers of ticks rendered are equal to the configured ticks + cy.get("line").then(($el) => { + expect($el.length).equals(legendConfigs[idx].ticks); + }); - // check if the rendered tick values are the same as the values configured - const stringTickValues = - legendLayer.properties.layerLegend.tickValues.map((val) => + // check if the rendered tick values are the same as the values configured + const stringTickValues = legendConfigs[idx].tickValues.map((val) => val.toFixed(1).toString(), ); - cy.get("text").then(($el) => { - $el.each((_idx, el) => { - expect(stringTickValues).include(el.textContent); + cy.get("text").then(($el) => { + $el.each((_idx, el) => { + expect(stringTickValues).include(el.textContent); + }); }); }); - }); + }); }); };