diff --git a/package.json b/package.json index af379f8..5dc0d03 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "leaflet-html", "type": "module", - "version": "0.13.6", + "version": "0.13.11", "description": "Leaflet maps expressed in HTML suitable for HTMX", "keywords": [ "leaflet", diff --git a/src/l-div-icon.js b/src/l-div-icon.js index 2880b67..c0937ab 100644 --- a/src/l-div-icon.js +++ b/src/l-div-icon.js @@ -16,6 +16,14 @@ export default class CustomElement extends HTMLElement { if (className !== null) { options["className"] = className; } + const iconAnchor = this.getAttribute("icon-anchor"); + if (iconAnchor !== null) { + options["iconAnchor"] = JSON.parse(iconAnchor); + } + const iconSize = this.getAttribute("icon-size"); + if (iconSize != null) { + options["iconSize"] = JSON.parse(iconSize); + } this.icon = divIcon(options); this.dispatchEvent( diff --git a/src/l-div-icon.test.js b/src/l-div-icon.test.js index e152bd6..fd243e6 100644 --- a/src/l-div-icon.test.js +++ b/src/l-div-icon.test.js @@ -6,9 +6,31 @@ import "./index.js"; it("should render a div icon", () => { const el = document.createElement("l-div-icon"); el.innerHTML = "Hello, World!"; + el.setAttribute("icon-anchor","[50, 50]"); + el.setAttribute("icon-size", "[100, 100]") document.body.appendChild(el); expect(el.icon).toBeInstanceOf(DivIcon); - expect(el.icon).toEqual(divIcon({ html: "Hello, World!" })); + expect(el.icon).toEqual(divIcon({ html: "Hello, World!" , iconAnchor: [50, 50], iconSize: [100, 100]})); +}); + +it("should assign null as iconSize when null passed", () => { + const el = document.createElement("l-div-icon"); + el.innerHTML = "Hello, World!"; + el.setAttribute("icon-anchor","[50, 50]"); + el.setAttribute("icon-size","null"); + document.body.appendChild(el); + expect(el.icon).toBeInstanceOf(DivIcon); + expect(el.icon).toEqual(divIcon({ html: "Hello, World!" , iconAnchor: [50, 50], iconSize: null})); +}); + +it("should have a default iconSize if not given", () => { + const el = document.createElement("l-div-icon"); + el.innerHTML = "Hello, World!"; + el.setAttribute("icon-anchor","[50, 50]"); + document.body.appendChild(el); + expect(el.icon).toBeInstanceOf(DivIcon); + expect(el.icon).toEqual(divIcon({ html: "Hello, World!" , iconAnchor: [50, 50]})); + expect(el.icon.options).toHaveProperty('iconSize', [12, 12]); }); it("should attach div icon to marker", () => { @@ -18,6 +40,5 @@ it("should attach div icon to marker", () => { marker.appendChild(icon); document.body.appendChild(marker); - const actual = marker.layer.getIcon(); expect(icon.icon).toEqual(marker.layer.getIcon()); }); diff --git a/src/l-geojson.js b/src/l-geojson.js index c3faf73..f6d499f 100644 --- a/src/l-geojson.js +++ b/src/l-geojson.js @@ -17,6 +17,10 @@ class LGeoJSON extends HTMLElement { if (pane !== null) { options["pane"] = pane.getAttribute("name"); } + const style = this.getAttribute("style"); + if (style !== null) { + options["style"] = JSON.parse(style) + } if (value !== null) { this.layer = geoJSON(JSON.parse(value), options); diff --git a/src/l-geojson.test.js b/src/l-geojson.test.js new file mode 100644 index 0000000..94962bc --- /dev/null +++ b/src/l-geojson.test.js @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { geoJson } from "leaflet"; +import { it, expect } from "vitest"; +import "./index.js"; +import { layerConnected } from "./events.js"; + +it("should render a geoJson object", async () => { + const el = document.createElement("l-geojson"); + el.setAttribute("geojson", "[[50, 0], [50, -1], [49, -1], [49,0]]"); + el.setAttribute("id", "test-layer"); + el.setAttribute("style", '{"color": "#0000ff"}'); + let promise = new Promise((resolve) => { + el.addEventListener(layerConnected, (ev) => { + resolve(ev.detail); + }); + }); + document.body.appendChild(el); + const actual = await promise; + const expected = { + layer: geoJson( + [ + [50, 0], + [50, -1], + [49, -1], + [49, 0], + ], + { style: { color: "#0000ff" } }, + ) + }; + expect(actual).toEqual(expected); +}); diff --git a/src/l-map.js b/src/l-map.js index f73f264..22d3a66 100644 --- a/src/l-map.js +++ b/src/l-map.js @@ -55,6 +55,15 @@ class LMap extends HTMLElement { connectedCallback() { const options = { zoomControl: this.hasAttribute("zoom-control") }; + if (this.hasAttribute("max-zoom")) { + options.maxZoom = parseFloat(this.getAttribute("max-zoom")); + } + if (this.hasAttribute("min-zoom")) { + options.minZoom = parseFloat(this.getAttribute("min-zoom")); + } + if (this.hasAttribute("max-bounds")) { + options.maxBounds = JSON.parse(this.getAttribute("max-bounds")); + } if (this.hasAttribute("attribution-control")) { options["attributionControl"] = this.getAttribute("attribution-control").toLowerCase() === "true"; } diff --git a/src/l-map.test.js b/src/l-map.test.js index 77ee079..5c76f29 100644 --- a/src/l-map.test.js +++ b/src/l-map.test.js @@ -4,7 +4,7 @@ import { layerRemoved, layerConnected } from "./events" import { vi, it, expect } from "vitest"; import LTileLayer from "./l-tile-layer"; import LMap from "./l-map.js"; -import { map } from "leaflet"; +import { map, latLngBounds } from "leaflet"; it("should emit map:addTo event(s)", async () => { // Arrange: create a ... arrangement @@ -142,3 +142,22 @@ it("should remove attributionControl given attribution-control=true attribute", document.body.appendChild(el); expect(el.map.attributionControl).not.toBe(undefined); }) + +it("should set maxZoom and minZoom given max-zoom and min-zoom attribute", () => { + const el = document.createElement("l-map"); + el.setAttribute("zoom", "0"); + el.setAttribute("center", "[0,0]"); + el.setAttribute("max-zoom", "5"); + el.setAttribute("min-zoom", "0"); + document.body.appendChild(el); + expect(el.map.options).toEqual({ zoomControl: false, maxZoom: 5, minZoom: 0 }); +}) + +it("should set maxBounds given max-bounds attribute", () => { + const el = document.createElement("l-map"); + el.setAttribute("zoom", "0"); + el.setAttribute("center", "[0,0]"); + el.setAttribute("max-bounds", "[[0, 0], [1, 1]]"); + document.body.appendChild(el); + expect(el.map.options).toEqual({ zoomControl: false, maxBounds: latLngBounds([[0, 0], [1, 1]]) }); +}) diff --git a/src/l-tile-layer-wms.js b/src/l-tile-layer-wms.js index ebc7e6e..d93cda5 100644 --- a/src/l-tile-layer-wms.js +++ b/src/l-tile-layer-wms.js @@ -7,7 +7,7 @@ import { gridLayerOptions } from "./grid-layer.js"; class LTileLayerWMS extends LLayer { static get observedAttributes() { - return ["options"]; + return ["options", "layers", "styles", "format", "transparent", "version", "crs", "uppercase"]; } constructor() { @@ -20,9 +20,14 @@ class LTileLayerWMS extends LLayer { } attributeChangedCallback(name, oldValue, newValue) { - if (name === "options" && oldValue !== newValue) { - if (this.isConnected) { - this.reloadLayer(); + if (this.layer && oldValue !== newValue) { + switch (name) { + case "options": + this.layer.setParams(this._parseNonStandardOptions(newValue)); + break; + default: + this.layer.setParams({ [name]: newValue }); + break; } } } @@ -42,26 +47,11 @@ class LTileLayerWMS extends LLayer { uppercase: optional(htmlAttribute("uppercase")), // Inherited option from Layer: https://leafletjs.com/reference.html#tilelayer-wms-attribution - attribution: optional(htmlAttribute("attribution")), + attribution: optional(htmlAttribute("attribution")) }); const standardOptions = parse(schema, this); - const nonStandardOptionsElement = this.getAttribute("options"); - const nonStandardOptions = () => { - if (nonStandardOptionsElement) { - try { - return JSON.parse(nonStandardOptionsElement); - } catch (e) { - console.error( - "Error whilst parsing JSON for options attribute in l-tile-layer-wms", - e, - ); - return {}; - } - } else { - return {}; - } - }; + const nonStandardOptions = this.getAttribute("options"); // Pane options const paneOptions = {}; @@ -75,22 +65,30 @@ class LTileLayerWMS extends LLayer { this.layer = tileLayer.wms(urlTemplate, { ...standardOptions, - ...nonStandardOptions(), + ...this._parseNonStandardOptions(nonStandardOptions), ...paneOptions, - ...gridOptions, + ...gridOptions }); const event = new CustomEvent(layerConnected, { detail: { name, layer: this.layer }, - bubbles: true, + bubbles: true }); this.dispatchEvent(event); } - reloadLayer() { - if (this.layer) { - this.layer.remove(); + _parseNonStandardOptions(nonStandardOptions) { + if (nonStandardOptions) { + try { + return JSON.parse(nonStandardOptions); + } catch (e) { + console.error( + "Error whilst parsing JSON for options attribute in l-tile-layer-wms", + e + ); + } } - this.initLayer(); + + return {}; } } diff --git a/src/l-tile-layer-wms.test.js b/src/l-tile-layer-wms.test.js index 8bece42..f27e5c0 100644 --- a/src/l-tile-layer-wms.test.js +++ b/src/l-tile-layer-wms.test.js @@ -1,8 +1,9 @@ // @vitest-environment happy-dom import { point, tileLayer } from "leaflet"; -import { it, expect } from "vitest"; +import { it, expect, vi } from "vitest"; import { layerConnected } from "./events"; import "./index"; +import { waitFor } from "@storybook/test"; it("should create an l-tile-layer-wms with the correct options", async () => { const urlTemplate = "http://ows.mundialis.de/services/service?"; @@ -92,82 +93,6 @@ it("should handle invalid JSON in the options attribute gracefully", () => { expect(actual).toEqual(expected); }); -it("should reload the layer when the options attribute changes", async () => { - const urlTemplate = "http://example.com/wms"; - const initialOptions = JSON.stringify({ height: 101, bbox: "coords ere" }); - const updatedOptions = JSON.stringify({ height: 202, bbox: "new coords" }); - - const el = document.createElement("l-tile-layer-wms"); - el.setAttribute("url-template", urlTemplate); - el.setAttribute("layers", "example layer ere"); - el.setAttribute("options", initialOptions); - - let layerConnectedEventEmittedCount = 0; - let promise = new Promise((resolve) => { - el.addEventListener(layerConnected, (ev) => { - layerConnectedEventEmittedCount += 1; - resolve(ev.detail); - }); - }); - - document.body.appendChild(el); - - // Wait for the initial layer to be created - let detail = await promise; - expect(detail.layer.options.height).toBe(101); - expect(detail.layer.options.bbox).toBe("coords ere"); - - // Change the options attribute - promise = new Promise((resolve) => { - el.addEventListener(layerConnected, (ev) => { - resolve(ev.detail); - }); - }); - - // Update the options attribute - el.setAttribute("options", updatedOptions); - - // Wait for the layer to reload - detail = await promise; - expect(detail.layer.options.height).toBe(202); - expect(detail.layer.options.bbox).toBe("new coords"); - expect(layerConnectedEventEmittedCount).toBe(2); // initial layer creation + reload -}); - -it("should not reload the layer when non-options attributes are changed", async () => { - const urlTemplate = "http://example.com/wms"; - const initialOptions = JSON.stringify({ height: 101, bbox: "coords ere" }); - - const el = document.createElement("l-tile-layer-wms"); - el.setAttribute("url-template", urlTemplate); - el.setAttribute("layers", "example layer ere"); - el.setAttribute("options", initialOptions); - - let layerConnectedEventEmittedCount = 0; - let promise = new Promise((resolve) => { - el.addEventListener(layerConnected, (ev) => { - layerConnectedEventEmittedCount += 1; - resolve(ev.detail); - }); - }); - - document.body.appendChild(el); - - // Wait for the initial layer to be created - let detail = await promise; - expect(detail.layer.options.height).toBe(101); - expect(detail.layer.options.bbox).toBe("coords ere"); - - // Update the a different attribute to options - el.setAttribute("a-different-attribute", "with different value"); - - // Give the layer a chance to reload - detail = await promise; - expect(detail.layer.options.height).toBe(101); - expect(detail.layer.options.bbox).toBe("coords ere"); - expect(layerConnectedEventEmittedCount).toBe(1); // initial layer creation only -}); - it.each([ ["512", 512], ["[256, 512]", point({ x: 256, y: 512 })], @@ -184,3 +109,22 @@ it.each([ const expected = tileLayer.wms(baseUrl, { layers, tileSize }); expect(actual).toEqual(expected); }); + +it.each([ + ["options", '{"banana": "yo"}', '{"banana": "ok"}', true], + ["transparent", "TRUE", "FALSE", false], +])("should update layer params when non-standard or standard attributes change", (attributeName, attributeInitialValue, attributeNewValue, isJson) => { + const baseUrl = "/"; + const layers = "layer-1"; + const el = document.createElement("l-tile-layer-wms"); + el.setAttribute("url-template", baseUrl); + el.setAttribute("layers", layers); + el.setAttribute(attributeName, attributeInitialValue); + + document.body.appendChild(el); + const setParams = vi.spyOn(el.layer, "setParams"); + + el.setAttribute(attributeName, attributeNewValue); + + expect(setParams).toHaveBeenCalledWith(isJson ? JSON.parse(attributeNewValue) : {[attributeName]: attributeNewValue}) +}); diff --git a/src/l-tile-layer.js b/src/l-tile-layer.js index b8de34a..a3d509e 100644 --- a/src/l-tile-layer.js +++ b/src/l-tile-layer.js @@ -39,6 +39,14 @@ class LTileLayer extends LLayer { }); const options = parse(schema, this); + const zoomOffset = this.getAttribute("zoom-offset"); + if (zoomOffset) { + const number = parseInt(zoomOffset); + if (!isNaN(number)) { + options["zoomOffset"] = number; + } + } + // GridLayer options const gridOptions = gridLayerOptions(this); diff --git a/src/l-tile-layer.test.js b/src/l-tile-layer.test.js index fa00595..085b5bd 100644 --- a/src/l-tile-layer.test.js +++ b/src/l-tile-layer.test.js @@ -76,3 +76,17 @@ it("should support tile-size attribute default value", () => { const expected = tileLayer(urlTemplate, {}); expect(actual).toEqual(expected); }); + +it.each([["-1", -1]])( + "should support zoom-offset attribute", + (text, zoomOffset) => { + const urlTemplate = "/"; + const el = document.createElement("l-tile-layer"); + el.setAttribute("url-template", urlTemplate); + el.setAttribute("zoom-offset", text); + document.body.appendChild(el); + const actual = el.layer; + const expected = tileLayer(urlTemplate, { zoomOffset }); + expect(actual).toEqual(expected); + }, +);