diff --git a/ts/components/LayersControl.ts b/ts/components/LayersControl.ts new file mode 100644 index 00000000..85c2ea22 --- /dev/null +++ b/ts/components/LayersControl.ts @@ -0,0 +1,386 @@ +import maplibregl from "maplibre-gl"; + +/* + * Most of the logic here is from https://github.com/mvt-proj/maplibre-gl-layers-control/tree/main + * Orignial license from https://github.com/mvt-proj/maplibre-gl-layers-control/blob/main/LICENSE: + * +BSD 3-Clause License + +Copyright (c) 2025, mvt-proj + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ +const defaultStyles = ` +.layers-control { + position: relative; + display: flex; + align-items: flex-start; +} + +.layers-control .panel { + position: absolute; + z-index: 1000; + display: none; + flex-direction: column; + background: white; + padding: 8px; + border: 1px solid rgba(0, 0, 0, 0.2); + min-width: 250px; + max-height: 700px; + overflow-y: auto; + overflow-x: auto; +} + +.panel-title { + font-size: 18px; + font-weight: bold; + text-align: center; + padding-bottom: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); +} + +.maplibregl-ctrl-top-left > .layers-control .panel, +.maplibregl-ctrl-top-right > .layers-control .panel { + top: 100%; +} + +.maplibregl-ctrl-bottom-left > .layers-control .panel, +.maplibregl-ctrl-bottom-right > .layers-control .panel { + bottom: 100%; +} + +.maplibregl-ctrl-top-left > .layers-control .panel, +.maplibregl-ctrl-bottom-left > .layers-control .panel { + left: 0; +} + +.maplibregl-ctrl-top-right > .layers-control .panel, +.maplibregl-ctrl-bottom-right > .layers-control .panel { + right: 0; +} + +.layers-control.open .panel { + display: flex; +} + +.layers-toggle-btn { + width: 40px; + height: 40px; + background-color: #fff; + /*border: none;*/ + cursor: pointer; + background-image: url('data:image/svg+xml;charset=utf-8,'); + background-repeat: no-repeat; + background-position: center; +} + +.layer-group { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); +} + +.layers-control .panel::-webkit-scrollbar { + width: 6px; +} + +.layers-control .panel::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.layer-name-label { + font-size: 16px; + margin-left: 4px; +} + +.opacity-row input[type="range"] { + width: 95%; +} + +`; + +type LayersControlOptions = { + title?: string; + customLabels?: Record; + legendServiceUrl?: string; + opacityControl: boolean; +}; + +const defaultOptions: LayersControlOptions = { + title: "Layers Control", + customLabels: {}, + legendServiceUrl: undefined, + opacityControl: false, +}; + +interface LayerMetadata { + alias?: string; + isbaselayer?: boolean; +} + +class LayersControl implements maplibregl.IControl { + private map?: maplibregl.Map; + private container!: HTMLDivElement; + private panel!: HTMLDivElement; + private toggleBtn!: HTMLButtonElement; + + private title?: string; + private customLabels: Record; + private legendServiceUrl?: string; + private opacityControlOption: boolean; + + constructor(options: Partial) { + this.title = options.title ?? defaultOptions.title; + this.customLabels = options.customLabels ?? defaultOptions.customLabels!; + this.legendServiceUrl = options.legendServiceUrl; + this.opacityControlOption = + options.opacityControl ?? defaultOptions.opacityControl; + } + + private injectStyles() { + if (document.getElementById("layers-control-styles")) return; + + const styleEl = document.createElement("style"); + styleEl.id = "layers-control-styles"; + styleEl.textContent = defaultStyles; + document.head.appendChild(styleEl); + } + + private createToggleButton() { + this.toggleBtn = document.createElement("button"); + this.toggleBtn.className = "maplibregl-ctrl-icon layers-toggle-btn"; + this.toggleBtn.type = "button"; + this.toggleBtn.title = this.title!; + this.toggleBtn.setAttribute("aria-label", "Mostrar capas"); + this.toggleBtn.addEventListener("click", () => { + this.container.classList.toggle("open"); + }); + this.container.appendChild(this.toggleBtn); + } + + private getBaseLayerIds(): string[] { + return ( + this.map!.getStyle() + // @ts-ignore: implicit any en layer + .layers?.filter((l) => l.metadata?.isbaselayer) + // @ts-ignore: implicit any en layer + .map((l) => l.id) ?? [] + ); + } + + private buildPanel() { + console.log("building panel"); + this.panel.innerHTML = `

${this.title}

`; + const layers = this.map!.getStyle().layers ?? []; + layers + .slice() + .reverse() + .forEach((layer: maplibregl.LayerSpecification) => { + const id: string = layer.id; + const type: string = layer.type; + const metadata = layer.metadata as + | { alias?: string; isbaselayer?: boolean } + | undefined; + const labelTitle = this.customLabels[id] ?? metadata?.alias ?? id; + const group = document.createElement("div"); + group.className = "layer-group"; + + if (type === "raster" && metadata?.isbaselayer) { + this.radioButtonControlAdd(id, labelTitle, group); + } else { + this.checkBoxControlAdd(id, labelTitle, group); + } + + if (this.opacityControlOption) { + this.rangeControlAdd(id, type, group); + } + + if (this.legendServiceUrl) { + this.fetchAndRenderLegend(id, group); + } + + this.panel.appendChild(group); + }); + } + + private radioButtonControlAdd( + layerId: string, + labelTitle: string, + parent: HTMLElement, + ) { + const baseIds = this.getBaseLayerIds(); + const initId = baseIds[0]; + const input = document.createElement("input"); + input.type = "radio"; + input.name = "base-layer"; + input.id = layerId; + + if (layerId === initId) { + input.checked = true; + this.map!.setLayoutProperty(layerId, "visibility", "visible"); + } else { + this.map!.setLayoutProperty(layerId, "visibility", "none"); + } + + input.addEventListener("change", () => { + baseIds.forEach((id) => { + const checked = id === layerId; + this.map!.setLayoutProperty( + id, + "visibility", + checked ? "visible" : "none", + ); + const el = document.getElementById(id) as HTMLInputElement | null; + if (el) el.checked = checked; + }); + }); + + // Label + const label = document.createElement("label"); + label.htmlFor = layerId; + label.className = "layer-name-label"; + label.textContent = labelTitle; + + parent.append(input, label); + } + + private checkBoxControlAdd( + layerId: string, + labelTitle: string, + parent: HTMLElement, + ) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.id = layerId; + + const vis = + this.map!.getLayoutProperty(layerId, "visibility") === "visible"; + input.checked = vis; + this.map!.setLayoutProperty( + layerId, + "visibility", + vis ? "visible" : "none", + ); + + input.addEventListener("change", (e) => { + const chk = (e.target as HTMLInputElement).checked; + this.map!.setLayoutProperty( + layerId, + "visibility", + chk ? "visible" : "none", + ); + }); + + const label = document.createElement("label"); + label.htmlFor = layerId; + label.className = "layer-name-label"; + label.textContent = labelTitle; + + parent.append(input, label); + } + + private rangeControlAdd(layerId: string, type: string, parent: HTMLElement) { + const wrapper = document.createElement("div"); + wrapper.className = "opacity-row"; + + const range = document.createElement("input"); + range.type = "range"; + range.min = "0"; + range.max = "100"; + + let propKey: string; + + if (type !== "symbol") { + propKey = `${type}-opacity`; + } else { + propKey = "text-opacity"; + } + + const curr = Number(this.map!.getPaintProperty(layerId, propKey)); + range.value = isNaN(curr) ? "100" : String(Math.round(curr * 100)); + + range.addEventListener("input", (e) => { + const v = Number((e.target as HTMLInputElement).value) / 100; + this.map!.setPaintProperty(layerId, propKey, v); + }); + + wrapper.append(range); + parent.append(wrapper); + } + + private async fetchAndRenderLegend(layerId: string, parent: HTMLElement) { + const url = new URL(this.legendServiceUrl!); + url.searchParams.set("layer_id", layerId); + url.searchParams.set("has_label", "false"); + url.searchParams.set("include_raster", "true"); + + const legend = document.createElement("div"); + legend.className = "legend-svg"; + legend.textContent = "Cargando…"; + parent.appendChild(legend); + + try { + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(String(res.status)); + const svg = await res.text(); + legend.innerHTML = svg; + } catch (err) { + legend.textContent = `Error leyenda: ${err}`; + legend.style.color = "red"; + } + } + + onAdd(map: maplibregl.Map): HTMLElement { + this.map = map; + + this.injectStyles(); + + this.container = document.createElement("div"); + this.container.className = "maplibregl-ctrl layers-control"; + + this.createToggleButton(); + + this.panel = document.createElement("div"); + this.panel.className = "panel"; + this.container.appendChild(this.panel); + + map.on("load", () => this.buildPanel()); + + return this.container; + } + + onRemove() { + this.container.parentNode?.removeChild(this.container); + this.map = undefined; + } + + getDefaultPosition(): maplibregl.ControlPosition { + return "top-left"; + } +} + +export default LayersControl; diff --git a/ts/components/MapProxiedArcgisTile.vue b/ts/components/MapProxiedArcgisTile.vue index 387a1486..954c2ef3 100644 --- a/ts/components/MapProxiedArcgisTile.vue +++ b/ts/components/MapProxiedArcgisTile.vue @@ -138,6 +138,7 @@ import { watch, } from "vue"; +import LayersControl from "@/components/LayersControl"; import { boundsMarkers, boundsDefault } from "@/map-utils"; import { MapClickEvent, Marker, Point } from "@/types"; import type { Location } from "@/type/api"; @@ -254,9 +255,9 @@ const initializeMap = () => { console.log("initial map fitting default bounds", bounds); _map.fitBounds(bounds); } - _map.addControl(new maplibregl.NavigationControl(), "top-left"); map.value = _map; _map.on("load", () => { + console.log("proxied tile loaded"); isLoaded.value = true; if (props.organizationId !== 0) { _map.addSource("tegola", { @@ -299,6 +300,13 @@ const initializeMap = () => { }); console.log("MapProxiedArcgisTile loaded"); }); + _map.addControl(new maplibregl.NavigationControl(), "top-left"); + _map.addControl( + new LayersControl({ + title: "layers", + customLabels: { "countries-fill": "Countries" }, + }), + ); console.log("MapProxiedArcgisTile initialized"); } catch (e) { console.error("hey dummy", e);