const json_files = [ "tileconfig/osm.json", "tileconfig/mapycom.json", "tileconfig/praha.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; let activeMouseMap = null; let activeMousePoint = null; let activeMouseLatLng = null; let TILE_SERVERS = {}; let maps = []; let baseLayers = []; let overlayLayers = []; let crosshairs = []; 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() { const state = { center, zoom, count: 2, layers: [] }; const match = window.location.hash.match(/^#map=([0-9.]+)\/(-?[0-9.]+)\/(-?[0-9.]+)/); if (match) { const z = parseFloat(match[1]); const lat = parseFloat(match[2]); const lng = parseFloat(match[3]); if (Number.isFinite(lat) && Number.isFinite(lng) && Number.isFinite(z)) { state.center = [lat, lng]; state.zoom = z; } } const params = new URLSearchParams(window.location.hash.replace(/^#map=[^&]*/, "")); const count = parseInt(params.get("count"), 10); if (mapCounts.includes(count)) { state.count = count; } for (let i = 1; i <= state.count; i++) { state.layers.push({ base: params.get("layer" + i), overlays: params.getAll("overlay" + i) }); } return state; } function getLayerSelectorState(index) { const selector = document.getElementById("select" + (parseInt(index, 10) + 1)); const overlays = []; selector.querySelectorAll(".layer-overlay-option input:checked").forEach(input => { overlays.push(input.dataset.layer); }); return { base: selector.dataset.baseLayer, overlays }; } function addUrlParam(parts, key, value) { if (value) parts.push(key + "=" + encodeURIComponent(value)); } function writeUrlState(map) { if (urlUpdateTimer) clearTimeout(urlUpdateTimer); 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); parts.push("count=" + count); 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); } function sync(source) { if (syncing) return; syncing = true; maps.forEach(target => { if (target !== source) { target.setView(source.getCenter(), source.getZoom(), { animate: false }); } }); syncing = false; writeUrlState(source); updateCrosshairs(); } function createLayer(config, zIndex) { if (config.type === "xyz") { return L.tileLayer(config.url, { maxZoom: 19, tileSize: 256, zIndex, referrerPolicy: "strict-origin-when-cross-origin" }); } if (config.type === "wms") { return L.tileLayer.wms(config.url, { layers: config.layers, format: config.format || "image/png", transparent: config.transparent ?? true, version: config.version || "1.3.0", attribution: config.attribution || "", zIndex, referrerPolicy: "strict-origin-when-cross-origin" }); } } 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]; } } function fillMapCountDropdown(select) { select.innerHTML = ""; mapCounts.forEach(count => { const opt = document.createElement("option"); opt.value = count; opt.textContent = count + (count === 1 ? " mapa" : count === 2 || count === 4 ? " mapy" : " map"); select.appendChild(opt); }); } function setMapCount(count) { const container = document.getElementById("container"); container.dataset.count = count; for (let i = 1; i <= maps.length; i++) { const wrapper = document.getElementById("map" + i).parentElement; wrapper.classList.toggle("hidden", i > count); } hideCrosshairs(); setTimeout(() => { maps.forEach((map, index) => { if (index < count) map.invalidateSize(false); }); }, 0); } function isVisibleMap(map) { return !map.getContainer().parentElement.classList.contains("hidden"); } function createCrosshair(map) { const crosshair = document.createElement("div"); crosshair.className = "crosshair"; map.getContainer().parentElement.appendChild(crosshair); return crosshair; } function hideCrosshairs() { activeMouseMap = null; activeMousePoint = null; activeMouseLatLng = null; crosshairs.forEach(crosshair => { crosshair.style.display = "none"; }); } function updateCrosshairs() { if (!activeMouseMap || !activeMousePoint || !isVisibleMap(activeMouseMap)) return; activeMouseLatLng = activeMouseMap.containerPointToLatLng(activeMousePoint); maps.forEach((map, index) => { const crosshair = crosshairs[index]; if (map === activeMouseMap || !isVisibleMap(map)) { crosshair.style.display = "none"; return; } const point = map.latLngToContainerPoint(activeMouseLatLng); const size = map.getSize(); if (point.x < 0 || point.y < 0 || point.x > size.x || point.y > size.y) { crosshair.style.display = "none"; return; } crosshair.style.left = point.x + "px"; crosshair.style.top = point.y + "px"; crosshair.style.display = "block"; }); } function initCrosshair(map) { const container = map.getContainer(); map.on("mousemove", e => { activeMouseMap = map; activeMousePoint = map.mouseEventToContainerPoint(e.originalEvent); activeMouseLatLng = e.latlng; updateCrosshairs(); }); map.on("mouseout", e => { if (!container.contains(e.originalEvent.relatedTarget)) hideCrosshairs(); }); map.on("move", () => updateCrosshairs()); map.on("zoom", () => updateCrosshairs()); } function init() { const urlState = readUrlState(); const mapCountSelector = document.getElementById("mapCountSelector"); fillMapCountDropdown(mapCountSelector); document.addEventListener("click", () => closeLayerSelectors()); 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)); map.on("zoom", () => sync(map)); L.control.zoom({ position: "topright" }).addTo(map); maps.push(map); baseLayers.push(null); overlayLayers.push({}); crosshairs.push(createCrosshair(map)); initCrosshair(map); const layerState = getLayerSelectorState(i - 1); setBaseLayer(i - 1, layerState.base); layerState.overlays.forEach(name => setOverlayLayer(i - 1, name, true)); } mapCountSelector.value = urlState.count; mapCountSelector.addEventListener("change", e => { setMapCount(parseInt(e.target.value, 10)); writeUrlState(maps[0]); }); setMapCount(parseInt(mapCountSelector.value, 10)); writeUrlState(maps[0]); } async function loadTileConfigs(json_files) { const results = await Promise.all( json_files.map(f => fetch(f).then(r => r.json())) ); // Merge all JSON objects into TILE_SERVERS results.forEach(obj => Object.assign(TILE_SERVERS, obj)); } loadTileConfigs(json_files).then(() => { init(); });