diff --git a/dve.css b/dve.css index 27874c9..64e9cdd 100644 --- a/dve.css +++ b/dve.css @@ -57,6 +57,66 @@ html, body { top: auto; bottom: 10px; } +.layer-selector { + padding: 0; + min-width: 190px; +} +.layer-selector-button { + width: 100%; + max-width: 280px; + border: 0; + background: transparent; + padding: 6px 24px 6px 8px; + text-align: left; + font: inherit; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.layer-selector-button::after { + content: "▾"; + position: absolute; + right: 8px; +} +.layer-selector-menu { + display: none; + min-width: 260px; + max-width: 360px; + max-height: 65vh; + overflow: auto; + border-top: 1px solid rgba(0,0,0,0.15); +} +.layer-selector.open .layer-selector-menu { + display: block; +} +.layer-group-title { + padding: 8px 8px 4px; + font-weight: bold; + color: #555; +} +.layer-option, +.layer-overlay-option { + display: block; + box-sizing: border-box; + width: 100%; + border: 0; + background: transparent; + padding: 5px 8px 5px 18px; + text-align: left; + font: inherit; + cursor: pointer; +} +.layer-option:hover, +.layer-overlay-option:hover { + background: rgba(0,0,0,0.08); +} +.layer-option.active { + font-weight: bold; +} +.layer-overlay-option input { + margin-right: 6px; +} .crosshair { position: absolute; diff --git a/dve.js b/dve.js index 4c99de8..0738ebe 100644 --- a/dve.js +++ b/dve.js @@ -1,12 +1,18 @@ const json_files = [ "tileconfig/osm.json", "tileconfig/mapycom.json", - "tileconfig/praha.json" + "tileconfig/praha.json", + "tileconfig/others.json" ]; const center = [50.08804, 14.42076]; const zoom = 14; const mapCounts = [1, 2, 4, 6, 9]; +const layerCategories = { + classic: "Klasické", + ortho: "Ortomapy", + overlay: "Overlaye" +}; let syncing = false; let urlUpdateTimer = null; @@ -14,18 +20,151 @@ let activeMouseMap = null; let activeMousePoint = null; let activeMouseLatLng = null; -let TILE_SERVERS = []; +let TILE_SERVERS = {}; let maps = []; -let layers = []; +let baseLayers = []; +let overlayLayers = []; let crosshairs = []; -function fillDropdown(select) { - for (const name in TILE_SERVERS) { - const opt = document.createElement("option"); - opt.value = name; - opt.textContent = name; - select.appendChild(opt); - } +function getLayerCategory(name, config) { + const category = (config.category || "").toString().toLowerCase(); + const lowerName = name.toLowerCase(); + + if (["overlay", "overlays", "prekryv", "překryv"].includes(category)) return "overlay"; + if (["ortho", "ortomap", "orthomap", "orthophoto", "ortofoto", "ortomapa"].includes(category)) return "ortho"; + if (["classic", "base", "normal", "klasicke", "klasické"].includes(category)) return "classic"; + + if (lowerName.includes("overlay")) return "overlay"; + if (lowerName.includes("orto")) return "ortho"; + + return "classic"; +} + +function getLayerNamesByCategory(category) { + return Object.keys(TILE_SERVERS).filter(name => getLayerCategory(name, TILE_SERVERS[name]) === category); +} + +function getBaseLayerNames() { + return Object.keys(TILE_SERVERS).filter(name => getLayerCategory(name, TILE_SERVERS[name]) !== "overlay"); +} + +function getValidBaseLayerName(name) { + if (name && TILE_SERVERS[name] && getLayerCategory(name, TILE_SERVERS[name]) !== "overlay") return name; + + return getBaseLayerNames()[0] || Object.keys(TILE_SERVERS)[0]; +} + +function getValidOverlayNames(names) { + const selected = []; + + names.forEach(name => { + if ( + !selected.includes(name) && + TILE_SERVERS[name] && + getLayerCategory(name, TILE_SERVERS[name]) === "overlay" + ) { + selected.push(name); + } + }); + + return selected; +} + +function closeLayerSelectors(except) { + document.querySelectorAll(".layer-selector.open").forEach(selector => { + if (selector !== except) selector.classList.remove("open"); + }); +} + +function updateLayerSelectorLabel(selector) { + const button = selector.querySelector(".layer-selector-button"); + const state = getLayerSelectorState(selector.dataset.index); + button.textContent = state.base + (state.overlays.length ? " +" + state.overlays.length : ""); + + selector.querySelectorAll(".layer-option").forEach(option => { + option.classList.toggle("active", option.dataset.layer === state.base); + }); +} + +function addLayerGroup(menu, title, names, overlay, selector) { + if (!names.length) return; + + const groupTitle = document.createElement("div"); + groupTitle.className = "layer-group-title"; + groupTitle.textContent = title; + menu.appendChild(groupTitle); + + names.forEach(name => { + if (overlay) { + const label = document.createElement("label"); + const input = document.createElement("input"); + label.className = "layer-overlay-option"; + input.type = "checkbox"; + input.dataset.layer = name; + input.checked = selector._initialOverlays.includes(name); + label.appendChild(input); + label.appendChild(document.createTextNode(name)); + menu.appendChild(label); + + input.addEventListener("change", e => { + const index = parseInt(selector.dataset.index, 10); + setOverlayLayer(index, name, e.target.checked); + updateLayerSelectorLabel(selector); + writeUrlState(maps[index]); + }); + } else { + const option = document.createElement("button"); + option.type = "button"; + option.className = "layer-option"; + option.dataset.layer = name; + option.textContent = name; + menu.appendChild(option); + + option.addEventListener("click", () => { + const index = parseInt(selector.dataset.index, 10); + selector.dataset.baseLayer = name; + setBaseLayer(index, name); + updateLayerSelectorLabel(selector); + selector.classList.remove("open"); + writeUrlState(maps[index]); + }); + } + }); +} + +function fillLayerSelector(selector, index, state) { + const baseLayer = getValidBaseLayerName(state.base); + const overlays = getValidOverlayNames(state.overlays || []); + + selector.innerHTML = ""; + selector.dataset.index = index; + selector.dataset.baseLayer = baseLayer; + selector._initialOverlays = overlays; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "layer-selector-button"; + selector.appendChild(button); + + const menu = document.createElement("div"); + menu.className = "layer-selector-menu"; + selector.appendChild(menu); + + addLayerGroup(menu, layerCategories.classic, getLayerNamesByCategory("classic"), false, selector); + addLayerGroup(menu, layerCategories.ortho, getLayerNamesByCategory("ortho"), false, selector); + addLayerGroup(menu, layerCategories.overlay, getLayerNamesByCategory("overlay"), true, selector); + + ["click", "mousedown", "dblclick", "wheel", "touchstart"].forEach(type => { + selector.addEventListener(type, e => e.stopPropagation()); + }); + + button.addEventListener("click", () => { + const open = !selector.classList.contains("open"); + closeLayerSelectors(selector); + selector.classList.toggle("open", open); + }); + + updateLayerSelectorLabel(selector); } function readUrlState() { @@ -57,23 +196,31 @@ function readUrlState() { } for (let i = 1; i <= state.count; i++) { - const layer = params.get("layer" + i); - - if (layer) state.layers.push(layer); + state.layers.push({ + base: params.get("layer" + i), + overlays: params.getAll("overlay" + i) + }); } return state; } -function getSelectedLayers(count) { - const selected = []; +function getLayerSelectorState(index) { + const selector = document.getElementById("select" + (parseInt(index, 10) + 1)); + const overlays = []; - for (let i = 1; i <= count; i++) { - const select = document.getElementById("select" + i); - if (select && select.value) selected.push(select.value); - } + selector.querySelectorAll(".layer-overlay-option input:checked").forEach(input => { + overlays.push(input.dataset.layer); + }); - return selected; + return { + base: selector.dataset.baseLayer, + overlays + }; +} + +function addUrlParam(parts, key, value) { + if (value) parts.push(key + "=" + encodeURIComponent(value)); } function writeUrlState(map) { @@ -82,15 +229,18 @@ function writeUrlState(map) { urlUpdateTimer = setTimeout(() => { const c = map.getCenter(); const count = parseInt(document.getElementById("mapCountSelector").value, 10); + const parts = []; let hash = "#map=" + map.getZoom() + "/" + c.lat.toFixed(5) + "/" + c.lng.toFixed(5); - hash += "&count=" + count; + parts.push("count=" + count); - const selected = getSelectedLayers(count); - selected.forEach((name, index) => { - hash += "&layer" + (index + 1) + "=" + encodeURIComponent(name); - }); + for (let i = 0; i < count; i++) { + const state = getLayerSelectorState(i); + addUrlParam(parts, "layer" + (i + 1), state.base); + state.overlays.forEach(name => addUrlParam(parts, "overlay" + (i + 1), name)); + } + hash += "&" + parts.join("&"); history.replaceState(null, "", hash); }, 100); } @@ -109,15 +259,14 @@ function sync(source) { updateCrosshairs(); } -function setLayer(map, config, currentLayer) { - if (currentLayer) map.removeLayer(currentLayer); - +function createLayer(config, zIndex) { if (config.type === "xyz") { return L.tileLayer(config.url, { maxZoom: 19, tileSize: 256, + zIndex, referrerPolicy: "strict-origin-when-cross-origin" - }).addTo(map); + }); } if (config.type === "wms") { @@ -127,8 +276,40 @@ function setLayer(map, config, currentLayer) { transparent: config.transparent ?? true, version: config.version || "1.3.0", attribution: config.attribution || "", + zIndex, referrerPolicy: "strict-origin-when-cross-origin" - }).addTo(map); + }); + } +} + +function setLayer(map, config, currentLayer, zIndex) { + if (currentLayer) map.removeLayer(currentLayer); + + return createLayer(config, zIndex).addTo(map); +} + +function bringOverlayLayersToFront(index) { + Object.values(overlayLayers[index]).forEach(layer => { + layer.setZIndex(100); + layer.bringToFront(); + }); +} + +function setBaseLayer(index, name) { + baseLayers[index] = setLayer(maps[index], TILE_SERVERS[name], baseLayers[index], 1); + bringOverlayLayersToFront(index); +} + +function setOverlayLayer(index, name, enabled) { + if (enabled) { + if (!overlayLayers[index][name]) { + overlayLayers[index][name] = createLayer(TILE_SERVERS[name], 100).addTo(maps[index]); + } + + overlayLayers[index][name].bringToFront(); + } else if (overlayLayers[index][name]) { + maps[index].removeLayer(overlayLayers[index][name]); + delete overlayLayers[index][name]; } } @@ -232,13 +413,11 @@ function init() { const mapCountSelector = document.getElementById("mapCountSelector"); fillMapCountDropdown(mapCountSelector); - for (let i = 1; i <= 9; i++) { - const select = document.getElementById("select" + i); - fillDropdown(select); + document.addEventListener("click", () => closeLayerSelectors()); - if (urlState.layers[i - 1] && TILE_SERVERS[urlState.layers[i - 1]]) { - select.value = urlState.layers[i - 1]; - } + for (let i = 1; i <= 9; i++) { + const selector = document.getElementById("select" + i); + fillLayerSelector(selector, i - 1, urlState.layers[i - 1] || {}); const map = L.map("map" + i, { center: urlState.center, zoom: urlState.zoom, zoomControl: false }); map.on("move", () => sync(map)); @@ -247,17 +426,14 @@ function init() { L.control.zoom({ position: "topright" }).addTo(map); maps.push(map); - layers.push(null); + baseLayers.push(null); + overlayLayers.push({}); crosshairs.push(createCrosshair(map)); initCrosshair(map); - layers[i - 1] = setLayer(map, TILE_SERVERS[select.value], layers[i - 1]); - - select.addEventListener("change", e => { - const key = e.target.value; - layers[i - 1] = setLayer(map, TILE_SERVERS[key], layers[i - 1]); - writeUrlState(map); - }); + const layerState = getLayerSelectorState(i - 1); + setBaseLayer(i - 1, layerState.base); + layerState.overlays.forEach(name => setOverlayLayer(i - 1, name, true)); } mapCountSelector.value = urlState.count; diff --git a/index.html b/index.html index e535ec3..04ec9a8 100644 --- a/index.html +++ b/index.html @@ -31,39 +31,39 @@