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);